//! Manifest validator. Enforces Constructor Pattern invariants. //! Hard-fails on missing obligatory blocks, missing handoffs, unknown blocks. //! //! Detailed sub-checks live in their own cubes: //! - `placeholders::check` — {{PLACEHOLDER}} substitution guard //! - `schemas_export::load` — dynamic artifact-schema whitelist loader //! - this file — structural checks + artifact-schema names use crate::manifest::Manifest; use crate::placeholders; use crate::schemas_export; use crate::substrate; use std::collections::BTreeSet; use std::path::Path; pub const OBLIGATORY: &[&str] = &["baseline", "evidence-grading", "memory-protocol"]; /// Back-compat alias for external callers. The SSoT lives in /// `schemas_export::BUILTIN`. #[allow(dead_code)] pub const KNOWN_ARTIFACT_SCHEMAS: &[&str] = schemas_export::BUILTIN; pub fn validate(m: &Manifest, blocks_dir: &Path) -> Result<(), String> { for required in OBLIGATORY { if !m.blocks.iter().any(|b| b == required) { return Err(format!("missing obligatory block: {required}")); } } if m.handoff.is_empty() { return Err("at least one handoff required".into()); } for block in &m.blocks { let path = blocks_dir.join(format!("{block}.md")); if !path.exists() { return Err(format!("block '{block}' not found at {}", path.display())); } } if m.domain_in.is_empty() { return Err("domain_in must have at least one entry".into()); } if m.forbidden_domain.is_empty() { return Err("forbidden_domain must have at least one entry".into()); } if m.role.trim().is_empty() { return Err("role must not be empty".into()); } placeholders::check(m)?; let known = schemas_export::load(blocks_dir); check_artifact_schemas(m, &known)?; check_substrate_role(m, blocks_dir)?; Ok(()) } /// If a manifest declares `substrate_role`, verify the role file exists /// and every capability it references has a `text.md`. Keeping the check /// here (not only at assemble time) turns mistakes into up-front failures. fn check_substrate_role(m: &Manifest, blocks_dir: &Path) -> Result<(), String> { let Some(role) = &m.substrate_role else { return Ok(()); }; let root = blocks_dir .parent() .ok_or_else(|| "blocks_dir has no parent (can't locate _roles/)".to_string())?; let caps = substrate::load_role_capabilities(root, role)?; for cap in &caps { substrate::load_capability_text(root, cap)?; } Ok(()) } /// v0.15: if a manifest references artifact schema names, they must be in the /// known whitelist. Missing fields are allowed (non-breaking extension). fn check_artifact_schemas(m: &Manifest, known: &BTreeSet) -> Result<(), String> { if let Some(name) = &m.produces_artifact { check_known(name, "produces_artifact", known)?; } for (i, h) in m.handoff.iter().enumerate() { if let Some(name) = &h.expects_artifact { check_known(name, &format!("handoff[{i}].expects_artifact"), known)?; } if let Some(name) = &h.produces_artifact { check_known(name, &format!("handoff[{i}].produces_artifact"), known)?; } } Ok(()) } fn check_known(name: &str, field: &str, known: &BTreeSet) -> Result<(), String> { if known.contains(name) { return Ok(()); } let list: Vec<&str> = known.iter().map(String::as_str).collect(); Err(format!( "unknown artifact schema '{name}' in field '{field}' — must be one of {list:?}" )) } #[cfg(test)] mod tests { use super::*; use crate::manifest::{Handoff, Manifest}; fn base() -> Manifest { Manifest { name: "test".into(), description: "d".into(), tools: vec!["Read".into()], model: "opus".into(), role: "r".into(), blocks: vec!["baseline".into(), "evidence-grading".into(), "memory-protocol".into()], domain_in: vec!["x".into()], forbidden_domain: vec!["y".into()], handoff: vec![Handoff { target: "a".into(), trigger: "b".into(), expects_artifact: None, produces_artifact: None, }], output_extra_fields: vec![], memory_project: None, project_claudemd: None, references: None, produces_artifact: None, substrate_role: None, rule_blocks: vec![], } } fn builtin_set() -> BTreeSet { schemas_export::BUILTIN.iter().map(|s| (*s).to_string()).collect() } #[test] fn artifact_schemas_absent_passes() { let m = base(); assert!(check_artifact_schemas(&m, &builtin_set()).is_ok()); } #[test] fn artifact_schemas_known_names_pass() { let mut m = base(); m.produces_artifact = Some("spec".into()); m.handoff[0].expects_artifact = Some("plan".into()); m.handoff[0].produces_artifact = Some("patch".into()); assert!(check_artifact_schemas(&m, &builtin_set()).is_ok()); } #[test] fn artifact_schemas_reject_unknown_produces() { let mut m = base(); m.produces_artifact = Some("not-a-schema".into()); let err = check_artifact_schemas(&m, &builtin_set()).unwrap_err(); assert!(err.contains("not-a-schema"), "err: {err}"); assert!(err.contains("produces_artifact"), "err: {err}"); } #[test] fn artifact_schemas_reject_unknown_expects_in_handoff() { let mut m = base(); m.handoff[0].expects_artifact = Some("zzz".into()); let err = check_artifact_schemas(&m, &builtin_set()).unwrap_err(); assert!(err.contains("zzz"), "err: {err}"); assert!(err.contains("handoff[0].expects_artifact"), "err: {err}"); } #[test] fn builtin_schemas_do_not_drift_from_kei_artifact() { // Structural drift test (no runtime dep on kei-artifact): read the // primitive's source and confirm its BUILTIN list matches ours. let primitive = Path::new(env!("CARGO_MANIFEST_DIR")) .join("..") .join("_primitives/_rust/kei-artifact/src/schemas.rs"); if !primitive.exists() { eprintln!("skip drift test: primitive not at {}", primitive.display()); return; } let src = std::fs::read_to_string(&primitive).unwrap(); let mut names: Vec = Vec::new(); for line in src.lines() { let t = line.trim(); if let Some(rest) = t.strip_prefix("(\"") { if let Some(end) = rest.find("\",") { names.push(rest[..end].to_string()); } } } let mine: Vec = schemas_export::BUILTIN .iter() .map(|s| (*s).to_string()) .collect(); assert_eq!( names, mine, "kei-artifact BUILTIN and schemas_export::BUILTIN drifted" ); } }