shepherd-compiler 6.6.0

Pure, deterministic Shepherd content compiler and prompt-budget engine.
//! Input validation for one compile.
//!
//! Every check that refuses a malformed or contradictory `CompileInput` before
//! any emission happens, so a partial tree is never produced.

// Tightly coupled to its siblings by construction: this is one compiler
// split by concern, not four independent modules.
use super::*;

pub(super) fn validate_model_resolution(
    role: &RoleInput,
    target: TargetHarness,
    resolution: &ModelResolution,
) -> Result<(), CompileError> {
    if let Some(model) = &resolution.model {
        validate_model_token(model)?;
    }
    if let Some(profile) = &resolution.profile {
        validate_identifier("model profile", profile)?;
    }
    if let Some(effort) = &resolution.reasoning_effort {
        validate_identifier("reasoning effort", effort)?;
    }

    let inherited = role.model_hint == "inherit-caller";
    let valid = match target {
        TargetHarness::Claude => {
            resolution.model.is_some()
                && resolution.profile.is_none()
                && resolution.reasoning_effort.is_none()
        }
        TargetHarness::Codex if inherited => {
            resolution.model.is_none()
                && resolution.profile.is_none()
                && resolution.reasoning_effort.is_none()
        }
        TargetHarness::Codex => {
            resolution.model.is_some()
                && resolution.profile.is_some()
                && resolution.reasoning_effort.is_some()
        }
        TargetHarness::Pi if inherited => {
            resolution.model.as_deref() == Some("inherit")
                && resolution.profile.is_none()
                && resolution.reasoning_effort.is_none()
        }
        TargetHarness::Pi => {
            resolution.model.is_none()
                && resolution.profile.is_none()
                && resolution.reasoning_effort.is_none()
        }
    };
    if !valid {
        let detail = if target == TargetHarness::Codex && !inherited {
            "model, profile, and reasoning effort"
        } else {
            "target-native model/profile fields"
        };
        return Err(CompileError::Invalid(format!(
            "{}: model mapping for `{}` must provide {detail} for target `{}`",
            role.source_path,
            role.model_hint,
            target.as_str()
        )));
    }
    Ok(())
}

pub(super) fn validate_input(input: &CompileInput) -> Result<(), CompileError> {
    if input.roles.is_empty() {
        return Err(CompileError::Invalid("content has zero roles".into()));
    }
    if input.skills.is_empty() {
        return Err(CompileError::Invalid("content has zero skills".into()));
    }
    let mut role_names = BTreeSet::new();
    for role in &input.roles {
        validate_identifier("role", &role.role)?;
        if !role_names.insert(role.role.as_str()) {
            return Err(CompileError::Invalid(format!(
                "duplicate role `{}`",
                role.role
            )));
        }
        validate_planter_contract(role)?;
        validate_canonical_dispatch_topology(role)?;
        validate_description(&role.source_path, &role.description)?;
        validate_source_path(&role.source_path)?;
        validate_identifier("model hint", &role.model_hint)?;
        validate_identifier("startup skill", &role.startup_skill)?;
        if role.capabilities.is_empty() {
            return Err(CompileError::Invalid(format!(
                "{}: capabilities must not be empty",
                role.source_path
            )));
        }
        for capability in &role.capabilities {
            validate_identifier("capability", capability)?;
        }
        if role.body.trim().is_empty() {
            return Err(CompileError::Invalid(format!(
                "{}: role body must not be empty",
                role.source_path
            )));
        }
        if role.source_content.is_empty() {
            return Err(CompileError::Invalid(format!(
                "{}: source content is empty",
                role.source_path
            )));
        }
    }
    let mut skill_names = BTreeSet::new();
    for skill in &input.skills {
        validate_identifier("skill", &skill.name)?;
        if !skill_names.insert(skill.name.as_str()) {
            return Err(CompileError::Invalid(format!(
                "duplicate skill `{}`",
                skill.name
            )));
        }
        validate_description(&skill.source_path, &skill.description)?;
        validate_source_path(&skill.source_path)?;
        if skill.body.trim().is_empty() {
            return Err(CompileError::Invalid(format!(
                "{}: skill body must not be empty",
                skill.source_path
            )));
        }
        if skill.source_content.is_empty() {
            return Err(CompileError::Invalid(format!(
                "{}: source content is empty",
                skill.source_path
            )));
        }
        let source_prefix = skill.source_path.strip_suffix("SKILL.md").ok_or_else(|| {
            CompileError::Invalid(format!(
                "{}: skill source must end with SKILL.md",
                skill.source_path
            ))
        })?;
        let mut resource_paths = BTreeSet::new();
        let mut resource_bytes = 0usize;
        for resource in &skill.resources {
            validate_resource_path(&resource.relative_path)?;
            if !resource_paths.insert(resource.relative_path.as_str()) {
                return Err(CompileError::Invalid(format!(
                    "{}: duplicate skill resource `{}`",
                    skill.source_path, resource.relative_path
                )));
            }
            validate_source_path(&resource.source_path)?;
            let expected_source = format!("{source_prefix}{}", resource.relative_path);
            if resource.source_path != expected_source {
                return Err(CompileError::Invalid(format!(
                    "{}: resource source path does not match `{expected_source}`",
                    resource.source_path
                )));
            }
            let script = resource.relative_path.starts_with("scripts/");
            if resource.executable != script {
                return Err(CompileError::Invalid(format!(
                    "{}: resource executable mode contradicts its category",
                    resource.source_path
                )));
            }
            if resource.content.len() > MAX_RESOURCE_BYTES {
                return Err(CompileError::Invalid(format!(
                    "{}: skill resource exceeds {MAX_RESOURCE_BYTES} bytes",
                    resource.source_path
                )));
            }
            resource_bytes = resource_bytes
                .checked_add(resource.content.len())
                .ok_or_else(|| {
                    CompileError::Invalid("skill resource byte count overflow".into())
                })?;
            if resource_bytes > MAX_SKILL_RESOURCE_BYTES {
                return Err(CompileError::Invalid(format!(
                    "{}: skill resources exceed {MAX_SKILL_RESOURCE_BYTES} bytes",
                    skill.source_path
                )));
            }
            let text = core::str::from_utf8(&resource.content).map_err(|_| {
                CompileError::Invalid(format!(
                    "{}: skill resource must be UTF-8",
                    resource.source_path
                ))
            })?;
            if resource.relative_path.starts_with("references/") && !text.is_empty() {
                validate_budget(&resource.source_path, BudgetClass::Reference, text)?;
            }
        }
    }
    Ok(())
}

pub(super) fn validate_canonical_dispatch_topology(role: &RoleInput) -> Result<(), CompileError> {
    let expected = match role.role.as_str() {
        "shepherd" | "planter" => Some(false),
        "auditor" | "coder" | "conductor" | "critic" | "discovery" | "engineer" | "worker" => {
            Some(true)
        }
        _ => None,
    };
    if let Some(expected) = expected
        && role.dispatchable != expected
    {
        let authority = if expected { "child" } else { "root" };
        return Err(CompileError::Invalid(format!(
            "canonical role topology requires `{}` to be a {authority} with dispatchable: {expected}",
            role.role
        )));
    }
    Ok(())
}

pub(super) fn validate_planter_contract(role: &RoleInput) -> Result<(), CompileError> {
    if role.role != "planter" {
        return Ok(());
    }
    let capabilities_match = role.capabilities.len() == PLANTER_CAPABILITIES.len()
        && role
            .capabilities
            .iter()
            .zip(PLANTER_CAPABILITIES)
            .all(|(actual, expected)| actual == expected);
    if !capabilities_match {
        return Err(CompileError::Invalid(
            "planter contract requires the exact native capability set".into(),
        ));
    }
    if role.dispatchable {
        return Err(CompileError::Invalid(
            "planter contract requires dispatchable: false".into(),
        ));
    }
    if role.write_scope != PLANTER_WRITE_SCOPE {
        return Err(CompileError::Invalid(
            "planter contract requires the exact mesh.md and seed.md write scope".into(),
        ));
    }
    if !role.write_eligible || role.startup_skill != "planting" {
        return Err(CompileError::Invalid(
            "planter contract requires write eligibility and the planting skill".into(),
        ));
    }
    Ok(())
}

pub(super) fn validate_resource_path(resource_path: &str) -> Result<(), CompileError> {
    if validate_source_path(resource_path).is_err() {
        return Err(CompileError::Invalid(format!(
            "invalid skill resource path `{resource_path}`"
        )));
    }
    let mut segments = resource_path.split('/');
    let category = segments.next().unwrap_or_default();
    let filename = segments.next().unwrap_or_default();
    if !matches!(category, "assets" | "references" | "scripts")
        || filename.is_empty()
        || segments.next().is_some()
    {
        return Err(CompileError::Invalid(format!(
            "invalid skill resource path `{resource_path}`"
        )));
    }
    Ok(())
}

pub(super) fn validate_source_path(source_path: &str) -> Result<(), CompileError> {
    if source_path.is_empty()
        || source_path.starts_with('/')
        || source_path.contains('\\')
        || source_path
            .split('/')
            .any(|segment| matches!(segment, "" | "." | ".."))
    {
        return Err(CompileError::Invalid(
            "source path must be a non-empty relative slash path without traversal".into(),
        ));
    }
    Ok(())
}

pub(super) fn validate_identifier(kind: &str, value: &str) -> Result<(), CompileError> {
    if ["true", "false", "null", "yes", "no", "on", "off"].contains(&value) {
        return Err(CompileError::Invalid(format!(
            "YAML-reserved {kind} identifier `{value}`"
        )));
    }
    if value.len() > 64
        || !value.as_bytes().first().is_some_and(u8::is_ascii_lowercase)
        || !value
            .bytes()
            .skip(1)
            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
    {
        return Err(CompileError::Invalid(format!(
            "invalid {kind} identifier `{value}`"
        )));
    }
    Ok(())
}

pub(super) fn validate_description(path: &str, description: &str) -> Result<(), CompileError> {
    if description.trim().is_empty() {
        return Err(CompileError::Invalid(format!(
            "{path}: description must not be empty"
        )));
    }
    if description.chars().count() > 500 {
        return Err(CompileError::Invalid(format!(
            "{path}: description exceeds 500 characters"
        )));
    }
    if description.contains(['\r', '\n']) {
        return Err(CompileError::Invalid(format!(
            "{path}: description must be one line"
        )));
    }
    Ok(())
}

pub(super) fn validate_tool(tool: &str) -> Result<(), CompileError> {
    if tool.is_empty()
        || tool.len() > 64
        || !tool
            .bytes()
            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
    {
        return Err(CompileError::Invalid(
            "harness tool names must be non-empty ASCII tokens".into(),
        ));
    }
    Ok(())
}

pub(super) fn validate_model_token(model: &str) -> Result<(), CompileError> {
    if model.is_empty()
        || model.len() > 128
        || model
            .bytes()
            .any(|byte| byte.is_ascii_control() || matches!(byte, b'"' | b'\\'))
    {
        return Err(CompileError::Invalid(
            "harness model names must be non-empty safe scalar tokens".into(),
        ));
    }
    Ok(())
}