schemalint 1.0.1

Static analysis tool for JSON Schema compatibility with LLM structured-output providers
Documentation
use serde_json::Value;

use crate::ir::{Arena, NodeId};
use crate::profile::{Profile, Severity};
use crate::rules::class_b::helpers::{missing_required_properties, schema_is_object};
use crate::rules::metadata::{RuleCategory, RuleMetadata};
use crate::rules::registry::{Diagnostic, DiagnosticSeverity, Rule};

#[derive(Debug, Clone)]
pub(super) struct ObjectRootRule {
    pub(super) profile_name: String,
}

impl Rule for ObjectRootRule {
    fn check(&self, node: NodeId, arena: &Arena, profile: &Profile) -> Vec<Diagnostic> {
        if arena[node].parent.is_some() || schema_is_object(&arena[node]) {
            return Vec::new();
        }
        vec![Diagnostic {
            code: format!("{}-S-object-root", profile.code_prefix),
            severity: DiagnosticSeverity::Error,
            message: "root schema must be an object".to_string(),
            pointer: arena[node].json_pointer.clone(),
            source: None,
            profile: self.profile_name.clone(),
            hint: None,
        }]
    }

    fn metadata(&self) -> Option<RuleMetadata> {
        Some(RuleMetadata {
            name: "object-root".into(),
            code: "{prefix}-S-object-root".into(),
            description: "The root schema must be of type object".into(),
            rationale: "Structured-output providers require the top-level schema to be an object."
                .into(),
            severity: Severity::Forbid,
            category: RuleCategory::Structural,
            bad_example: r#"{
  "type": "array",
  "items": { "type": "string" }
}"#
            .into(),
            good_example: r#"{
  "type": "object",
  "properties": { "name": { "type": "string" } }
}"#
            .into(),
            see_also: Vec::new(),
            profile: Some(self.profile_name.clone()),
        })
    }
}

#[derive(Debug, Clone)]
pub(super) struct AdditionalPropertiesFalseRule {
    pub(super) profile_name: String,
}

impl Rule for AdditionalPropertiesFalseRule {
    fn check(&self, node: NodeId, arena: &Arena, profile: &Profile) -> Vec<Diagnostic> {
        let node_ref = &arena[node];
        if !schema_is_object(node_ref) {
            return Vec::new();
        }
        match &node_ref.annotations.additional_properties {
            Some(Value::Bool(false)) => Vec::new(),
            _ => vec![Diagnostic {
                code: format!("{}-S-additional-properties-false", profile.code_prefix),
                severity: DiagnosticSeverity::Error,
                message: "object must declare additionalProperties: false".to_string(),
                pointer: node_ref.json_pointer.clone(),
                source: None,
                profile: self.profile_name.clone(),
                hint: None,
            }],
        }
    }

    fn metadata(&self) -> Option<RuleMetadata> {
        Some(RuleMetadata {
            name: "additional-properties-false".into(),
            code: "{prefix}-S-additional-properties-false".into(),
            description: "Every object schema must declare additionalProperties: false".into(),
            rationale: "Providers require object nodes to explicitly reject extra properties."
                .into(),
            severity: Severity::Forbid,
            category: RuleCategory::Structural,
            bad_example: r#"{ "type": "object", "properties": { "name": { "type": "string" } } }"#
                .into(),
            good_example: r#"{ "type": "object", "additionalProperties": false, "properties": { "name": { "type": "string" } } }"#.into(),
            see_also: Vec::new(),
            profile: Some(self.profile_name.clone()),
        })
    }
}

#[derive(Debug, Clone)]
pub(super) struct AllPropertiesRequiredRule {
    pub(super) profile_name: String,
}

impl Rule for AllPropertiesRequiredRule {
    fn check(&self, node: NodeId, arena: &Arena, profile: &Profile) -> Vec<Diagnostic> {
        let node_ref = &arena[node];
        if !schema_is_object(node_ref) {
            return Vec::new();
        }
        let missing = missing_required_properties(node_ref);
        if missing.is_empty() {
            return Vec::new();
        }

        vec![Diagnostic {
            code: format!("{}-S-all-properties-required", profile.code_prefix),
            severity: DiagnosticSeverity::Error,
            message: format!(
                "object has {} propert{} not listed in required: {}",
                missing.len(),
                if missing.len() == 1 { "y" } else { "ies" },
                missing
                    .iter()
                    .take(8)
                    .map(|key| format!("'{}'", key))
                    .collect::<Vec<_>>()
                    .join(", ")
            ),
            pointer: node_ref.json_pointer.clone(),
            source: None,
            profile: self.profile_name.clone(),
            hint: if missing.len() > 8 {
                Some(format!(
                    "Add all object properties to required; first 8 shown, {} total missing",
                    missing.len()
                ))
            } else {
                Some("Add every object property to required".to_string())
            },
        }]
    }

    fn metadata(&self) -> Option<RuleMetadata> {
        Some(RuleMetadata {
            name: "all-properties-required".into(),
            code: "{prefix}-S-all-properties-required".into(),
            description: "Every property must be listed in the required array".into(),
            rationale: "Some providers reject schemas with optional object properties.".into(),
            severity: Severity::Forbid,
            category: RuleCategory::Structural,
            bad_example: r#"{ "type": "object", "properties": { "name": { "type": "string" }, "age": { "type": "number" } }, "required": ["name"] }"#.into(),
            good_example: r#"{ "type": "object", "properties": { "name": { "type": "string" }, "age": { "type": "number" } }, "required": ["name", "age"] }"#.into(),
            see_also: Vec::new(),
            profile: Some(self.profile_name.clone()),
        })
    }
}