use std::path::{Path, PathBuf};
use crate::config::StrayMarkConfig;
use crate::document::{self, StrayMarkDocument, DocType};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
Error,
Warning,
}
#[derive(Debug, Clone)]
pub struct ValidationIssue {
pub file: PathBuf,
pub rule: String,
pub message: String,
pub severity: Severity,
pub fix_hint: Option<String>,
}
#[derive(Debug, Default)]
pub struct ValidationResult {
pub errors: Vec<ValidationIssue>,
pub warnings: Vec<ValidationIssue>,
}
impl ValidationResult {
pub fn merge(&mut self, other: ValidationResult) {
self.errors.extend(other.errors);
self.warnings.extend(other.warnings);
}
fn add(&mut self, issue: ValidationIssue) {
match issue.severity {
Severity::Error => self.errors.push(issue),
Severity::Warning => self.warnings.push(issue),
}
}
}
const VALID_STATUSES: &[&str] = &[
"draft",
"identified",
"review",
"accepted",
"resolved",
"superseded",
"deprecated",
];
const VALID_RISK_LEVELS: &[&str] = &["low", "medium", "high", "critical"];
const VALID_CONFIDENCES: &[&str] = &["low", "medium", "high"];
const SENSITIVE_PATTERNS: &[&str] = &[
"password:", "api_key:", "secret:", "private_key:",
"credentials:", "AWS_SECRET", "PRIVATE KEY",
];
const SOFT_SENSITIVE_PATTERNS: &[&str] = &[
"token:", "Bearer ",
];
fn china_in_scope(straymark_dir: &Path) -> bool {
let project_root = straymark_dir.parent().unwrap_or(straymark_dir);
let config = StrayMarkConfig::load(project_root).unwrap_or_default();
config.has_region("china")
}
pub fn validate_charters(project_root: &Path, straymark_dir: &Path) -> (ValidationResult, usize) {
let mut result = ValidationResult::default();
let schema = match crate::charter_schema::CharterSchema::load(straymark_dir) {
Ok(s) => Some(s),
Err(e) => {
result.warnings.push(ValidationIssue {
file: straymark_dir.join(crate::charter_schema::SCHEMA_RELATIVE_PATH),
rule: "CHARTER-SCHEMA-MISSING".to_string(),
message: format!("Charter schema not loadable: {e}"),
severity: Severity::Warning,
fix_hint: Some(
"Run `straymark repair` to restore framework files.".to_string(),
),
});
None
}
};
let paths = crate::charter::discover_charters(project_root);
let charter_count = paths.len();
for path in &paths {
let raw_yaml = match crate::charter::read_frontmatter_yaml(path) {
Ok(y) => y,
Err(e) => {
result.errors.push(ValidationIssue {
file: path.clone(),
rule: "CHARTER-PARSE".to_string(),
message: format!("Failed to read Charter: {e}"),
severity: Severity::Error,
fix_hint: Some(
"Check that the file has valid YAML frontmatter between --- delimiters."
.to_string(),
),
});
continue;
}
};
if let Some(schema) = &schema {
for issue in schema.validate(&raw_yaml, path) {
result.errors.push(issue);
}
}
let typed: Option<crate::charter::CharterFrontmatter> =
serde_yaml::from_value(raw_yaml).ok();
let typed = match typed {
Some(t) => t,
None => continue,
};
if let Some(ailogs) = &typed.originating_ailogs {
for ailog_id in ailogs {
if !ailog_exists(straymark_dir, ailog_id) {
result.errors.push(ValidationIssue {
file: path.clone(),
rule: "CHARTER-AILOG-REF".to_string(),
message: format!(
"originating_ailogs references missing AILOG: {}",
ailog_id
),
severity: Severity::Error,
fix_hint: Some(format!(
"Either create the AILOG (e.g., `straymark new --doc-type ailog`) or \
remove '{}' from originating_ailogs if it was a typo.",
ailog_id
)),
});
}
}
}
if let Some(spec_path) = &typed.originating_spec {
let abs = project_root.join(spec_path);
if !abs.exists() {
result.errors.push(ValidationIssue {
file: path.clone(),
rule: "CHARTER-SPEC-REF".to_string(),
message: format!(
"originating_spec references missing file: {}",
spec_path
),
severity: Severity::Error,
fix_hint: Some(
"Pass a path that exists under the project root (e.g., \
specs/001-feature/spec.md), or remove originating_spec if it was a typo."
.to_string(),
),
});
}
}
}
(result, charter_count)
}
fn ailog_exists(straymark_dir: &Path, ailog_id: &str) -> bool {
let agent_logs = straymark_dir.join("07-ai-audit").join("agent-logs");
if !agent_logs.exists() {
return false;
}
let id = ailog_id.trim_end_matches(".md");
let entries = match std::fs::read_dir(&agent_logs) {
Ok(e) => e,
Err(_) => return false,
};
for entry in entries.flatten() {
let name = entry.file_name();
let name = match name.to_str() {
Some(s) => s,
None => continue,
};
if let Some(rest) = name.strip_prefix(id) {
if rest == ".md" || rest.starts_with('-') {
return true;
}
}
}
false
}
pub fn validate_all(straymark_dir: &Path) -> (ValidationResult, usize) {
let paths = document::discover_documents(straymark_dir);
let doc_count = paths.len();
let mut result = ValidationResult::default();
let china = china_in_scope(straymark_dir);
for path in &paths {
match document::parse_document(path) {
Ok(doc) => {
result.merge(validate_document(&doc, straymark_dir, china));
}
Err(e) => {
result.errors.push(ValidationIssue {
file: path.clone(),
rule: "PARSE-001".to_string(),
message: format!("Failed to parse document: {e}"),
severity: Severity::Error,
fix_hint: Some("Check that the file has valid YAML frontmatter between --- delimiters".to_string()),
});
}
}
}
check_orphan_documents(&mut result, &paths, straymark_dir);
(result, doc_count)
}
pub fn check_pending_reviews(straymark_dir: &Path, max_pending_days: i64) -> Vec<ValidationIssue> {
use chrono::{Local, NaiveDate};
let mut issues = Vec::new();
let today = Local::now().date_naive();
let paths = document::discover_documents(straymark_dir);
for path in paths {
let doc = match document::parse_document(&path) {
Ok(d) => d,
Err(_) => continue, };
if !doc.frontmatter.review_required.unwrap_or(false) {
continue;
}
if doc.frontmatter.review_outcome.is_some() {
continue; }
let created = match doc
.frontmatter
.created
.as_deref()
.and_then(|s| NaiveDate::parse_from_str(s.trim(), "%Y-%m-%d").ok())
{
Some(d) => d,
None => continue, };
let age_days = (today - created).num_days();
if age_days < max_pending_days {
continue;
}
let id = doc
.frontmatter
.id
.clone()
.unwrap_or_else(|| {
path.file_stem()
.and_then(|s| s.to_str())
.map(String::from)
.unwrap_or_default()
});
issues.push(ValidationIssue {
file: path,
rule: "REVIEW-PENDING".to_string(),
message: format!(
"{} has `review_required: true` and no `review_outcome` ({} days since creation)",
id, age_days
),
severity: Severity::Warning,
fix_hint: Some(format!(
"Run `straymark approve {} --outcome <approved|revisions_requested|rejected> --reviewer <id>` once a human has reviewed.",
id
)),
});
}
issues
}
pub fn validate_paths(paths: &[PathBuf], straymark_dir: &Path) -> (ValidationResult, usize) {
let mut result = ValidationResult::default();
let mut doc_count = 0;
let china = china_in_scope(straymark_dir);
for path in paths {
if !path.exists() {
continue;
}
let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if document::detect_doc_type(filename).is_none() {
continue;
}
match document::parse_document(path) {
Ok(doc) => {
doc_count += 1;
result.merge(validate_document(&doc, straymark_dir, china));
}
Err(e) => {
doc_count += 1;
result.errors.push(ValidationIssue {
file: path.clone(),
rule: "PARSE-001".to_string(),
message: format!("Failed to parse document: {e}"),
severity: Severity::Error,
fix_hint: Some(
"Check that the file has valid YAML frontmatter between --- delimiters"
.to_string(),
),
});
}
}
}
(result, doc_count)
}
fn check_orphan_documents(result: &mut ValidationResult, paths: &[PathBuf], _straymark_dir: &Path) {
let parsed: Vec<StrayMarkDocument> = paths
.iter()
.filter_map(|p| document::parse_document(p).ok())
.collect();
let mut referenced: std::collections::HashSet<String> = std::collections::HashSet::new();
for doc in &parsed {
if let Some(related) = &doc.frontmatter.related {
for rel_id in related {
if !rel_id.is_empty() {
referenced.insert(rel_id.clone());
}
}
}
}
if parsed.len() <= 2 {
return;
}
let standalone_types = [
DocType::Eth,
DocType::Inc,
DocType::Tde,
DocType::Sec,
DocType::Mcard,
DocType::Dpia,
DocType::Sbom,
];
for doc in &parsed {
if standalone_types.contains(&doc.doc_type) {
continue;
}
let has_related = doc
.frontmatter
.related
.as_ref()
.is_some_and(|r| r.iter().any(|s| !s.is_empty()));
let is_referenced = referenced.iter().any(|r| doc.filename.starts_with(r.as_str()));
if !has_related && !is_referenced {
result.add(ValidationIssue {
file: doc.path.clone(),
rule: "REF-002".to_string(),
message: "Document has no traceability links (not in any related field and has no related of its own)".to_string(),
severity: Severity::Warning,
fix_hint: Some("Add a 'related' field linking to relevant documents for audit traceability".to_string()),
});
}
}
}
fn validate_document(
doc: &StrayMarkDocument,
straymark_dir: &Path,
china_in_scope: bool,
) -> ValidationResult {
let mut result = ValidationResult::default();
check_naming(&mut result, doc);
check_required_meta(&mut result, doc);
check_id_matches_filename(&mut result, doc);
check_valid_status(&mut result, doc);
check_cross_rules(&mut result, doc);
check_type_specific(&mut result, doc);
check_date_consistency(&mut result, doc);
check_related_exist(&mut result, doc, straymark_dir);
check_sensitive_info(&mut result, doc);
check_observability(&mut result, doc);
if china_in_scope {
check_china_cross_rules(&mut result, doc);
check_china_type_specific(&mut result, doc);
}
result
}
fn check_naming(result: &mut ValidationResult, doc: &StrayMarkDocument) {
let name = &doc.filename;
let prefix = doc.doc_type.prefix();
let after_prefix = match name.strip_prefix(&format!("{}-", prefix)) {
Some(rest) => rest,
None => {
result.add(ValidationIssue {
file: doc.path.clone(),
rule: "NAMING-001".to_string(),
message: format!("Filename should start with '{}-'", prefix),
severity: Severity::Error,
fix_hint: None,
});
return;
}
};
let head: String = after_prefix.chars().take(10).collect();
if head.chars().count() < 10 {
result.add(ValidationIssue {
file: doc.path.clone(),
rule: "NAMING-001".to_string(),
message: "Filename missing date component (expected YYYY-MM-DD after prefix)".to_string(),
severity: Severity::Error,
fix_hint: None,
});
return;
}
if !head.is_ascii() {
result.add(ValidationIssue {
file: doc.path.clone(),
rule: "NAMING-001".to_string(),
message: format!("Invalid date in filename: '{}'", head),
severity: Severity::Error,
fix_hint: None,
});
return;
}
let date_part = head.as_str(); let bytes = date_part.as_bytes();
let valid_date = bytes[4] == b'-'
&& bytes[7] == b'-'
&& date_part[..4].bytes().all(|b| b.is_ascii_digit())
&& date_part[5..7].bytes().all(|b| b.is_ascii_digit())
&& date_part[8..10].bytes().all(|b| b.is_ascii_digit());
if !valid_date {
result.add(ValidationIssue {
file: doc.path.clone(),
rule: "NAMING-001".to_string(),
message: format!("Invalid date in filename: '{}'", date_part),
severity: Severity::Error,
fix_hint: None,
});
return;
}
let after_date = &after_prefix[10..];
if !after_date.starts_with('-') {
result.add(ValidationIssue {
file: doc.path.clone(),
rule: "NAMING-001".to_string(),
message: "Missing sequence number after date (expected -NNN-)".to_string(),
severity: Severity::Error,
fix_hint: None,
});
return;
}
let after_dash = &after_date[1..]; let seq_end = after_dash.find('-').unwrap_or(after_dash.len());
let seq_part = &after_dash[..seq_end];
if seq_part.len() != 3 || !seq_part.chars().all(|c| c.is_ascii_digit()) {
result.add(ValidationIssue {
file: doc.path.clone(),
rule: "NAMING-002".to_string(),
message: format!(
"Sequence number should be exactly 3 digits (e.g., 001), found '{}'",
seq_part
),
severity: Severity::Warning,
fix_hint: Some(format!(
"Rename with zero-padded sequence: {:0>3}",
seq_part
)),
});
}
if seq_end < after_dash.len() {
let desc_with_ext = &after_dash[seq_end + 1..]; let desc = desc_with_ext.strip_suffix(".md").unwrap_or(desc_with_ext);
if !desc.is_empty()
&& !desc
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
result.add(ValidationIssue {
file: doc.path.clone(),
rule: "NAMING-003".to_string(),
message: format!(
"Description should be kebab-case (lowercase, digits, hyphens only), found '{}'",
desc
),
severity: Severity::Warning,
fix_hint: None,
});
}
}
}
fn check_required_meta(result: &mut ValidationResult, doc: &StrayMarkDocument) {
let fm = &doc.frontmatter;
let file = &doc.path;
let required: &[(&str, bool)] = &[
("id", fm.id.is_some()),
("title", fm.title.is_some()),
("status", fm.status.is_some()),
("created", fm.created.is_some()),
("agent", fm.agent.is_some()),
("confidence", fm.confidence.is_some()),
("review_required", fm.review_required.is_some()),
("risk_level", fm.risk_level.is_some()),
];
for (field, present) in required {
if !present {
result.add(ValidationIssue {
file: file.clone(),
rule: "META-001".to_string(),
message: format!("Missing required field: {}", field),
severity: Severity::Error,
fix_hint: Some(format!("Add '{}' to the frontmatter", field)),
});
}
}
}
fn check_id_matches_filename(result: &mut ValidationResult, doc: &StrayMarkDocument) {
if let Some(id) = &doc.frontmatter.id {
let expected_prefix = doc.doc_type.prefix();
if !id.starts_with(expected_prefix) {
result.add(ValidationIssue {
file: doc.path.clone(),
rule: "META-002".to_string(),
message: format!(
"Frontmatter id '{}' does not match filename prefix '{}'",
id, expected_prefix
),
severity: Severity::Error,
fix_hint: Some(format!("Change id to start with '{}-'", expected_prefix)),
});
}
}
}
fn check_valid_status(result: &mut ValidationResult, doc: &StrayMarkDocument) {
if let Some(status) = &doc.frontmatter.status {
if !VALID_STATUSES.contains(&status.as_str()) {
result.add(ValidationIssue {
file: doc.path.clone(),
rule: "META-003".to_string(),
message: format!(
"Invalid status '{}'. Valid values: {}",
status,
VALID_STATUSES.join(", ")
),
severity: Severity::Error,
fix_hint: None,
});
}
}
}
fn check_cross_rules(result: &mut ValidationResult, doc: &StrayMarkDocument) {
let fm = &doc.frontmatter;
if let Some(risk) = &fm.risk_level {
if (risk == "high" || risk == "critical") && fm.review_required != Some(true) {
result.add(ValidationIssue {
file: doc.path.clone(),
rule: "CROSS-001".to_string(),
message: format!(
"risk_level is '{}' but review_required is not true",
risk
),
severity: Severity::Error,
fix_hint: Some("Set review_required: true".to_string()),
});
}
}
if let Some(risk) = &fm.risk_level {
if !VALID_RISK_LEVELS.contains(&risk.as_str()) {
result.add(ValidationIssue {
file: doc.path.clone(),
rule: "META-003".to_string(),
message: format!(
"Invalid risk_level '{}'. Valid values: {}",
risk,
VALID_RISK_LEVELS.join(", ")
),
severity: Severity::Error,
fix_hint: None,
});
}
}
if let Some(conf) = &fm.confidence {
if !VALID_CONFIDENCES.contains(&conf.as_str()) {
result.add(ValidationIssue {
file: doc.path.clone(),
rule: "META-003".to_string(),
message: format!(
"Invalid confidence '{}'. Valid values: {}",
conf,
VALID_CONFIDENCES.join(", ")
),
severity: Severity::Error,
fix_hint: None,
});
}
}
if let Some(eu_risk) = &fm.eu_ai_act_risk {
if eu_risk == "high" && fm.review_required != Some(true) {
result.add(ValidationIssue {
file: doc.path.clone(),
rule: "CROSS-002".to_string(),
message: "eu_ai_act_risk is 'high' but review_required is not true".to_string(),
severity: Severity::Error,
fix_hint: Some("Set review_required: true".to_string()),
});
}
}
let always_review_types = [DocType::Sec, DocType::Mcard, DocType::Dpia];
if always_review_types.contains(&doc.doc_type) && fm.review_required != Some(true) {
result.add(ValidationIssue {
file: doc.path.clone(),
rule: "CROSS-003".to_string(),
message: format!(
"{} documents must have review_required: true",
doc.doc_type
),
severity: Severity::Error,
fix_hint: Some("Set review_required: true".to_string()),
});
}
}
fn check_type_specific(result: &mut ValidationResult, doc: &StrayMarkDocument) {
let fm = &doc.frontmatter;
if doc.doc_type == DocType::Inc && fm.severity.is_none() {
result.add(ValidationIssue {
file: doc.path.clone(),
rule: "TYPE-001".to_string(),
message: "INC documents must have a 'severity' field (SEV1/SEV2/SEV3/SEV4)".to_string(),
severity: Severity::Error,
fix_hint: Some("Add 'severity: SEV3' to the frontmatter".to_string()),
});
}
if doc.doc_type == DocType::Eth
&& (doc.body.contains("Data Privacy") || doc.body.contains("Privacidad de Datos"))
&& fm.gdpr_legal_basis.is_none()
{
result.add(ValidationIssue {
file: doc.path.clone(),
rule: "TYPE-002".to_string(),
message: "ETH document mentions Data Privacy but lacks 'gdpr_legal_basis' field".to_string(),
severity: Severity::Warning,
fix_hint: Some("Add 'gdpr_legal_basis: consent' (or appropriate basis) to the frontmatter".to_string()),
});
}
}
fn related_has_prefix(doc: &StrayMarkDocument, prefix: &str) -> bool {
doc.frontmatter
.related
.as_ref()
.is_some_and(|rels| rels.iter().any(|r| r.starts_with(prefix)))
}
fn check_china_cross_rules(result: &mut ValidationResult, doc: &StrayMarkDocument) {
let fm = &doc.frontmatter;
if let Some(level) = &fm.tc260_risk_level {
if matches!(
level.as_str(),
"high" | "very_high" | "extremely_severe"
) && fm.review_required != Some(true)
{
result.add(ValidationIssue {
file: doc.path.clone(),
rule: "CROSS-004".to_string(),
message: format!(
"tc260_risk_level is '{}' but review_required is not true",
level
),
severity: Severity::Error,
fix_hint: Some("Set review_required: true".to_string()),
});
}
}
if fm.pipl_sensitive_data == Some(true)
&& doc.doc_type != DocType::Pipia
&& !related_has_prefix(doc, "PIPIA-")
{
result.add(ValidationIssue {
file: doc.path.clone(),
rule: "CROSS-005".to_string(),
message: "pipl_sensitive_data is true but no PIPIA is linked in 'related'".to_string(),
severity: Severity::Error,
fix_hint: Some("Create a PIPIA and add 'PIPIA-...' to related".to_string()),
});
}
if let Some(status) = &fm.cac_filing_status {
let approved =
matches!(status.as_str(), "provincial_approved" | "national_approved");
let has_number = fm
.cac_filing_number
.as_deref()
.is_some_and(|n| !n.is_empty());
if approved && !has_number {
result.add(ValidationIssue {
file: doc.path.clone(),
rule: "CROSS-006".to_string(),
message: format!(
"cac_filing_status is '{}' but cac_filing_number is missing",
status
),
severity: Severity::Error,
fix_hint: Some(
"Populate cac_filing_number with the filing reference issued by CAC"
.to_string(),
),
});
}
}
if fm.cac_filing_required == Some(true)
&& doc.doc_type != DocType::Cacfile
&& !related_has_prefix(doc, "CACFILE-")
{
result.add(ValidationIssue {
file: doc.path.clone(),
rule: "CROSS-007".to_string(),
message: "cac_filing_required is true but no CACFILE is linked in 'related'"
.to_string(),
severity: Severity::Error,
fix_hint: Some("Create a CACFILE and add 'CACFILE-...' to related".to_string()),
});
}
if fm.csl_severity_level.as_deref() == Some("particularly_serious")
&& fm.csl_report_deadline_hours != Some(1)
{
result.add(ValidationIssue {
file: doc.path.clone(),
rule: "CROSS-008".to_string(),
message: "csl_severity_level 'particularly_serious' requires csl_report_deadline_hours: 1"
.to_string(),
severity: Severity::Error,
fix_hint: Some(
"CSL 2026: particularly serious incidents must be reported within 1 hour"
.to_string(),
),
});
}
if fm.csl_severity_level.as_deref() == Some("relatively_major")
&& fm.csl_report_deadline_hours != Some(4)
{
result.add(ValidationIssue {
file: doc.path.clone(),
rule: "CROSS-009".to_string(),
message: "csl_severity_level 'relatively_major' requires csl_report_deadline_hours: 4"
.to_string(),
severity: Severity::Error,
fix_hint: Some(
"CSL 2026: relatively major incidents must be reported within 4 hours"
.to_string(),
),
});
}
if fm.gb45438_applicable == Some(true)
&& doc.doc_type != DocType::Ailabel
&& !related_has_prefix(doc, "AILABEL-")
{
result.add(ValidationIssue {
file: doc.path.clone(),
rule: "CROSS-010".to_string(),
message: "gb45438_applicable is true but no AILABEL is linked in 'related'"
.to_string(),
severity: Severity::Error,
fix_hint: Some(
"Create an AILABEL describing explicit + implicit labeling per GB 45438"
.to_string(),
),
});
}
if doc.doc_type == DocType::Pipia
&& fm.pipl_cross_border_transfer == Some(true)
&& !doc.body.to_lowercase().contains("security_assessment")
&& !doc.body.to_lowercase().contains("security review")
&& !doc.body.to_lowercase().contains("standard_contract")
&& !doc.body.to_lowercase().contains("standard contract")
&& !doc.body.to_lowercase().contains("certification")
{
result.add(ValidationIssue {
file: doc.path.clone(),
rule: "CROSS-011".to_string(),
message: "PIPIA with cross-border transfer should document the chosen mechanism (security assessment / certification / standard contract)".to_string(),
severity: Severity::Warning,
fix_hint: Some(
"Complete the 'Cross-Border Transfer Analysis' section of the PIPIA".to_string(),
),
});
}
}
fn check_china_type_specific(result: &mut ValidationResult, doc: &StrayMarkDocument) {
let fm = &doc.frontmatter;
if doc.doc_type == DocType::Pipia {
let ok = match (fm.created.as_deref(), fm.pipl_retention_until.as_deref()) {
(Some(c), Some(u)) => retention_satisfies_three_years(c, u),
_ => false,
};
if !ok {
result.add(ValidationIssue {
file: doc.path.clone(),
rule: "TYPE-003".to_string(),
message: "PIPIA must declare pipl_retention_until at least 3 years after 'created' (PIPL Art. 56)".to_string(),
severity: Severity::Error,
fix_hint: Some(
"Set pipl_retention_until: <created + 3 years or later> in YYYY-MM-DD format"
.to_string(),
),
});
}
}
if doc.doc_type == DocType::Cacfile && fm.cac_filing_status.is_none() {
result.add(ValidationIssue {
file: doc.path.clone(),
rule: "TYPE-004".to_string(),
message: "CACFILE documents must have a 'cac_filing_status' field".to_string(),
severity: Severity::Error,
fix_hint: Some(
"Add 'cac_filing_status: pending' (or the current state) to the frontmatter"
.to_string(),
),
});
}
if doc.doc_type == DocType::Tc260ra {
let missing: Vec<&str> = [
("tc260_application_scenario", fm.tc260_application_scenario.is_some()),
("tc260_intelligence_level", fm.tc260_intelligence_level.is_some()),
("tc260_application_scale", fm.tc260_application_scale.is_some()),
]
.into_iter()
.filter_map(|(name, ok)| (!ok).then_some(name))
.collect();
if !missing.is_empty() {
result.add(ValidationIssue {
file: doc.path.clone(),
rule: "TYPE-005".to_string(),
message: format!(
"TC260RA documents must populate all three grading criteria. Missing: {}",
missing.join(", ")
),
severity: Severity::Error,
fix_hint: Some(
"Set tc260_application_scenario, tc260_intelligence_level, and tc260_application_scale"
.to_string(),
),
});
}
}
if doc.doc_type == DocType::Ailabel {
let count = fm
.gb45438_content_types
.as_ref()
.map(|v| v.len())
.unwrap_or(0);
if count == 0 {
result.add(ValidationIssue {
file: doc.path.clone(),
rule: "TYPE-006".to_string(),
message: "AILABEL documents must declare at least one entry in 'gb45438_content_types'".to_string(),
severity: Severity::Error,
fix_hint: Some(
"Set gb45438_content_types to a subset of: text, image, audio, video, virtual_scene"
.to_string(),
),
});
}
}
}
fn parse_iso_date(s: &str) -> Option<(i32, u32, u32)> {
if s.len() < 10 {
return None;
}
let y: i32 = s[..4].parse().ok()?;
let m: u32 = s[5..7].parse().ok()?;
let d: u32 = s[8..10].parse().ok()?;
Some((y, m, d))
}
fn retention_satisfies_three_years(created: &str, until_date: &str) -> bool {
let (cy, cm, cd) = match parse_iso_date(created) {
Some(t) => t,
None => return false,
};
let (uy, um, ud) = match parse_iso_date(until_date) {
Some(t) => t,
None => return false,
};
(uy, um, ud) >= (cy + 3, cm, cd)
}
fn check_related_exist(result: &mut ValidationResult, doc: &StrayMarkDocument, straymark_dir: &Path) {
if let Some(related) = &doc.frontmatter.related {
for rel_id in related {
if rel_id.is_empty() {
continue;
}
if !looks_like_straymark_id(rel_id) {
continue;
}
if !find_document_by_id(straymark_dir, rel_id) {
result.add(ValidationIssue {
file: doc.path.clone(),
rule: "REF-001".to_string(),
message: format!("Related document '{}' not found in .straymark/", rel_id),
severity: Severity::Warning,
fix_hint: None,
});
}
}
}
}
fn looks_like_straymark_id(id: &str) -> bool {
DocType::ALL_PREFIXES.iter().any(|prefix| {
id.starts_with(prefix) && id.get(prefix.len()..prefix.len() + 1) == Some("-")
})
}
fn check_date_consistency(result: &mut ValidationResult, doc: &StrayMarkDocument) {
let Some(created) = &doc.frontmatter.created else {
return;
};
let prefix = doc.doc_type.prefix();
let after_prefix = match doc.filename.strip_prefix(&format!("{}-", prefix)) {
Some(rest) => rest,
_ => return,
};
let filename_date: String = after_prefix.chars().take(10).collect();
if filename_date.chars().count() < 10 {
return;
}
let created_date: String = created.chars().take(10).collect();
if filename_date != created_date {
result.add(ValidationIssue {
file: doc.path.clone(),
rule: "META-004".to_string(),
message: format!(
"Filename date '{}' does not match created field '{}'",
filename_date, created_date
),
severity: Severity::Warning,
fix_hint: None,
});
}
}
fn find_document_by_id(straymark_dir: &Path, id: &str) -> bool {
let docs = document::discover_documents(straymark_dir);
docs.iter().any(|p| {
p.file_name()
.and_then(|n| n.to_str())
.map(|n| n.starts_with(id))
.unwrap_or(false)
})
}
fn check_sensitive_info(result: &mut ValidationResult, doc: &StrayMarkDocument) {
let full_content = doc.body.to_string();
for pattern in SENSITIVE_PATTERNS {
if full_content.contains(pattern) {
result.add(ValidationIssue {
file: doc.path.clone(),
rule: "SEC-001".to_string(),
message: format!("Possible sensitive information detected: '{}'", pattern.trim()),
severity: Severity::Error,
fix_hint: Some("Remove or redact sensitive information before committing".to_string()),
});
}
}
for pattern in SOFT_SENSITIVE_PATTERNS {
if full_content.contains(pattern) {
result.add(ValidationIssue {
file: doc.path.clone(),
rule: "SEC-001".to_string(),
message: format!("Review for sensitive information: '{}' (may be documentation context)", pattern.trim()),
severity: Severity::Warning,
fix_hint: Some("Verify this is documentation context, not an actual secret".to_string()),
});
}
}
}
fn check_observability(result: &mut ValidationResult, doc: &StrayMarkDocument) {
let has_obs_tag = doc.frontmatter.tags.as_ref().is_some_and(|tags| {
tags.iter().any(|t| t == "observabilidad" || t == "observability")
});
if has_obs_tag {
let has_obs_section = doc.body.contains("## Observability")
|| doc.body.contains("## Observabilidad")
|| doc.body.contains("instrumentation")
|| doc.body.contains("instrumentación")
|| doc.body.contains("OpenTelemetry")
|| doc.body.contains("observability_scope");
if !has_obs_section {
result.add(ValidationIssue {
file: doc.path.clone(),
rule: "OBS-001".to_string(),
message: "Document tagged with 'observabilidad'/'observability' but lacks observability-related content".to_string(),
severity: Severity::Warning,
fix_hint: Some("Add a section describing the instrumentation scope or observability risks".to_string()),
});
}
}
}
pub fn apply_fixes(doc: &StrayMarkDocument) -> Option<String> {
let content = match std::fs::read_to_string(&doc.path) {
Ok(c) => c,
Err(_) => return None,
};
let mut modified = false;
let mut new_content = content.clone();
let needs_review = doc.frontmatter.risk_level.as_deref() == Some("high")
|| doc.frontmatter.risk_level.as_deref() == Some("critical")
|| doc.frontmatter.eu_ai_act_risk.as_deref() == Some("high")
|| matches!(doc.doc_type, DocType::Sec | DocType::Mcard | DocType::Dpia);
if needs_review && doc.frontmatter.review_required != Some(true) {
if new_content.contains("review_required: false") {
new_content = new_content.replacen("review_required: false", "review_required: true", 1);
modified = true;
} else if doc.frontmatter.review_required.is_none() {
if let Some(pos) = new_content.find("risk_level:") {
if let Some(line_end) = new_content[pos..].find('\n') {
let insert_pos = pos + line_end + 1;
new_content.insert_str(insert_pos, "review_required: true\n");
modified = true;
}
}
}
}
if let Some(id) = &doc.frontmatter.id {
let expected_prefix = doc.doc_type.prefix();
if !id.starts_with(expected_prefix) {
let name_no_ext = doc.filename.strip_suffix(".md").unwrap_or(&doc.filename);
if let Some(dash_pos) = name_no_ext.find('-') {
let after_type = &name_no_ext[dash_pos + 1..];
let head: String = after_type.chars().take(14).collect();
if head.chars().count() == 14 {
let new_id = format!("{}-{}", expected_prefix, head);
let old_id_line = format!("id: {}", id);
let new_id_line = format!("id: {}", new_id);
if new_content.contains(&old_id_line) {
new_content = new_content.replacen(&old_id_line, &new_id_line, 1);
modified = true;
}
}
}
}
}
if modified {
Some(new_content)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::document::Frontmatter;
fn make_doc(filename: &str, doc_type: DocType, fm: Frontmatter, body: &str) -> StrayMarkDocument {
StrayMarkDocument {
path: PathBuf::from(format!(".straymark/test/{}", filename)),
filename: filename.to_string(),
doc_type,
frontmatter: fm,
body: body.to_string(),
}
}
#[test]
fn test_cross_001_high_risk_needs_review() {
let fm = Frontmatter {
id: Some("AILOG-2025-01-01-001".into()),
risk_level: Some("high".into()),
review_required: Some(false),
..Default::default()
};
let doc = make_doc("AILOG-2025-01-01-001-test.md", DocType::Ailog, fm, "");
let mut result = ValidationResult::default();
check_cross_rules(&mut result, &doc);
assert!(result.errors.iter().any(|e| e.rule == "CROSS-001"));
}
#[test]
fn test_cross_003_sec_needs_review() {
let fm = Frontmatter {
id: Some("SEC-2025-01-01-001".into()),
review_required: Some(false),
..Default::default()
};
let doc = make_doc("SEC-2025-01-01-001-test.md", DocType::Sec, fm, "");
let mut result = ValidationResult::default();
check_cross_rules(&mut result, &doc);
assert!(result.errors.iter().any(|e| e.rule == "CROSS-003"));
}
#[test]
fn test_sec_001_sensitive_info() {
let fm = Frontmatter::default();
let doc = make_doc("AILOG-2025-01-01-001-test.md", DocType::Ailog, fm, "The api_key: sk-12345 was used");
let mut result = ValidationResult::default();
check_sensitive_info(&mut result, &doc);
assert!(result.errors.iter().any(|e| e.rule == "SEC-001"));
}
#[test]
fn test_type_001_inc_needs_severity() {
let fm = Frontmatter {
id: Some("INC-2025-01-01-001".into()),
..Default::default()
};
let doc = make_doc("INC-2025-01-01-001-test.md", DocType::Inc, fm, "");
let mut result = ValidationResult::default();
check_type_specific(&mut result, &doc);
assert!(result.errors.iter().any(|e| e.rule == "TYPE-001"));
}
#[test]
fn test_cross_004_tc260_high_needs_review() {
let fm = Frontmatter {
id: Some("ETH-2026-04-25-001".into()),
tc260_risk_level: Some("very_high".into()),
review_required: Some(false),
..Default::default()
};
let doc = make_doc("ETH-2026-04-25-001-test.md", DocType::Eth, fm, "");
let mut result = ValidationResult::default();
check_china_cross_rules(&mut result, &doc);
assert!(result.errors.iter().any(|e| e.rule == "CROSS-004"));
}
#[test]
fn test_cross_005_pipl_sensitive_needs_pipia_link() {
let fm = Frontmatter {
id: Some("MCARD-2026-04-25-001".into()),
pipl_sensitive_data: Some(true),
related: Some(vec!["ETH-2026-04-25-001".into()]),
..Default::default()
};
let doc = make_doc("MCARD-2026-04-25-001-test.md", DocType::Mcard, fm, "");
let mut result = ValidationResult::default();
check_china_cross_rules(&mut result, &doc);
assert!(result.errors.iter().any(|e| e.rule == "CROSS-005"));
}
#[test]
fn test_cross_005_pipia_doc_itself_does_not_trigger() {
let fm = Frontmatter {
id: Some("PIPIA-2026-04-25-001".into()),
pipl_sensitive_data: Some(true),
..Default::default()
};
let doc = make_doc("PIPIA-2026-04-25-001-test.md", DocType::Pipia, fm, "");
let mut result = ValidationResult::default();
check_china_cross_rules(&mut result, &doc);
assert!(!result.errors.iter().any(|e| e.rule == "CROSS-005"));
}
#[test]
fn test_cross_006_approved_needs_filing_number() {
let fm = Frontmatter {
id: Some("CACFILE-2026-04-25-001".into()),
cac_filing_status: Some("national_approved".into()),
cac_filing_number: None,
..Default::default()
};
let doc = make_doc("CACFILE-2026-04-25-001-test.md", DocType::Cacfile, fm, "");
let mut result = ValidationResult::default();
check_china_cross_rules(&mut result, &doc);
assert!(result.errors.iter().any(|e| e.rule == "CROSS-006"));
}
#[test]
fn test_cross_007_filing_required_needs_cacfile() {
let fm = Frontmatter {
id: Some("MCARD-2026-04-25-001".into()),
cac_filing_required: Some(true),
..Default::default()
};
let doc = make_doc("MCARD-2026-04-25-001-test.md", DocType::Mcard, fm, "");
let mut result = ValidationResult::default();
check_china_cross_rules(&mut result, &doc);
assert!(result.errors.iter().any(|e| e.rule == "CROSS-007"));
}
#[test]
fn test_cross_008_particularly_serious_must_be_one_hour() {
let fm = Frontmatter {
id: Some("INC-2026-04-25-001".into()),
csl_severity_level: Some("particularly_serious".into()),
csl_report_deadline_hours: Some(4),
..Default::default()
};
let doc = make_doc("INC-2026-04-25-001-test.md", DocType::Inc, fm, "");
let mut result = ValidationResult::default();
check_china_cross_rules(&mut result, &doc);
assert!(result.errors.iter().any(|e| e.rule == "CROSS-008"));
}
#[test]
fn test_cross_009_relatively_major_must_be_four_hours() {
let fm = Frontmatter {
id: Some("INC-2026-04-25-001".into()),
csl_severity_level: Some("relatively_major".into()),
csl_report_deadline_hours: Some(24),
..Default::default()
};
let doc = make_doc("INC-2026-04-25-001-test.md", DocType::Inc, fm, "");
let mut result = ValidationResult::default();
check_china_cross_rules(&mut result, &doc);
assert!(result.errors.iter().any(|e| e.rule == "CROSS-009"));
}
#[test]
fn test_cross_010_gb45438_applicable_needs_ailabel() {
let fm = Frontmatter {
id: Some("MCARD-2026-04-25-001".into()),
gb45438_applicable: Some(true),
..Default::default()
};
let doc = make_doc("MCARD-2026-04-25-001-test.md", DocType::Mcard, fm, "");
let mut result = ValidationResult::default();
check_china_cross_rules(&mut result, &doc);
assert!(result.errors.iter().any(|e| e.rule == "CROSS-010"));
}
#[test]
fn test_type_003_pipia_retention_three_years() {
let fm = Frontmatter {
id: Some("PIPIA-2026-04-25-001".into()),
created: Some("2026-04-25".into()),
pipl_retention_until: Some("2027-04-25".into()), ..Default::default()
};
let doc = make_doc("PIPIA-2026-04-25-001-test.md", DocType::Pipia, fm, "");
let mut result = ValidationResult::default();
check_china_type_specific(&mut result, &doc);
assert!(result.errors.iter().any(|e| e.rule == "TYPE-003"));
}
#[test]
fn test_type_003_pipia_retention_three_years_ok() {
let fm = Frontmatter {
id: Some("PIPIA-2026-04-25-001".into()),
created: Some("2026-04-25".into()),
pipl_retention_until: Some("2029-04-25".into()), ..Default::default()
};
let doc = make_doc("PIPIA-2026-04-25-001-test.md", DocType::Pipia, fm, "");
let mut result = ValidationResult::default();
check_china_type_specific(&mut result, &doc);
assert!(!result.errors.iter().any(|e| e.rule == "TYPE-003"));
}
#[test]
fn test_type_005_tc260ra_requires_three_criteria() {
let fm = Frontmatter {
id: Some("TC260RA-2026-04-25-001".into()),
tc260_application_scenario: Some("healthcare".into()),
..Default::default()
};
let doc = make_doc("TC260RA-2026-04-25-001-test.md", DocType::Tc260ra, fm, "");
let mut result = ValidationResult::default();
check_china_type_specific(&mut result, &doc);
assert!(result.errors.iter().any(|e| e.rule == "TYPE-005"));
}
#[test]
fn test_type_006_ailabel_needs_content_type() {
let fm = Frontmatter {
id: Some("AILABEL-2026-04-25-001".into()),
gb45438_content_types: Some(vec![]),
..Default::default()
};
let doc = make_doc("AILABEL-2026-04-25-001-test.md", DocType::Ailabel, fm, "");
let mut result = ValidationResult::default();
check_china_type_specific(&mut result, &doc);
assert!(result.errors.iter().any(|e| e.rule == "TYPE-006"));
}
}