diff --git a/apps/backend/methodologies/exceptions.py b/apps/backend/methodologies/exceptions.py index 716197a7..7d866338 100644 --- a/apps/backend/methodologies/exceptions.py +++ b/apps/backend/methodologies/exceptions.py @@ -4,6 +4,14 @@ Defines all custom exceptions used by the methodologies/plugin system. Following architecture-defined patterns from Error-Handling-Patterns. """ +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + pass # No additional type imports needed + class AutoClaudeError(Exception): """Base exception for all Auto Claude errors. @@ -33,9 +41,26 @@ class ManifestValidationError(PluginError): - Field values have incorrect types - Schema validation fails - Version constraints cannot be satisfied + + Attributes: + path: Path to the invalid manifest file + errors: List of validation error messages """ - pass + def __init__(self, path: Path | str, errors: list[str]) -> None: + """Initialize with manifest path and error details. + + Args: + path: Path to the invalid manifest file + errors: List of validation error messages + """ + self.path: Path = path if isinstance(path, Path) else Path(path) + self.errors: list[str] = errors + if errors: + error_summary = "\n - " + "\n - ".join(errors) + else: + error_summary = " (no details)" + super().__init__(f"Invalid manifest at {path}:{error_summary}") class PluginLoadError(PluginError): diff --git a/apps/backend/methodologies/manifest.py b/apps/backend/methodologies/manifest.py new file mode 100644 index 00000000..1e8626a5 --- /dev/null +++ b/apps/backend/methodologies/manifest.py @@ -0,0 +1,402 @@ +"""Manifest loader and dataclasses for methodology plugins. + +This module provides the manifest loading functionality with JSON Schema validation. +It defines dataclasses representing the manifest structure and validates +manifest.yaml files against a schema before creating typed objects. + +Architecture Source: architecture.md#Manifest-Schema +""" + +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + +import jsonschema +import yaml + +from apps.backend.methodologies.exceptions import ManifestValidationError + + +def _is_valid_uri(uri: str) -> bool: + """Check if a string is a valid HTTP/HTTPS URI. + + For manifest metadata fields (homepage, repository), we only accept + HTTP and HTTPS schemes as these are the standard web URLs. + + Args: + uri: String to validate as URI + + Returns: + True if valid HTTP/HTTPS URI, False otherwise + """ + try: + result = urlparse(uri) + # Must have http/https scheme and netloc + return result.scheme in ("http", "https") and bool(result.netloc) + except (ValueError, AttributeError): + return False + + +# Create a format checker with URI validation +_format_checker = jsonschema.FormatChecker() + + +@_format_checker.checks("uri", raises=jsonschema.FormatError) +def _check_uri(instance: str) -> bool: + """Custom URI format checker for jsonschema.""" + if not isinstance(instance, str): + return True # Non-strings handled by type validation + if not _is_valid_uri(instance): + raise jsonschema.FormatError(f"'{instance}' is not a valid URI") + return True + + +@dataclass +class PhaseDefinition: + """Definition of a methodology phase from manifest.yaml. + + Represents a phase entry in the manifest's phases list. + Each phase defines a distinct stage in the methodology pipeline. + + Attributes: + id: Unique identifier for the phase + name: Human-readable name for display + description: Optional detailed description of the phase + artifacts: List of artifact IDs produced by this phase + """ + + id: str + name: str + description: str = "" + artifacts: list[str] = field(default_factory=list) + + +@dataclass +class CheckpointDefinition: + """Definition of a checkpoint from manifest.yaml. + + Represents a checkpoint entry for Semi-Auto pause points. + Checkpoints define where the methodology pauses for user review. + + Attributes: + id: Unique identifier for the checkpoint + phase: ID of the phase this checkpoint follows + description: What the user should review at this checkpoint + """ + + id: str + phase: str + description: str = "" + + +@dataclass +class ArtifactDefinition: + """Definition of an artifact from manifest.yaml. + + Represents an artifact entry that defines methodology outputs. + + Attributes: + id: Unique identifier for the artifact + name: Human-readable name for display + type: Type of artifact (markdown, json, yaml, etc.) + path: File path string relative to spec directory (e.g., "spec.md") + """ + + id: str + name: str + type: str = "markdown" + path: str = "" + + +@dataclass +class MethodologyManifest: + """Complete manifest for a methodology plugin. + + Contains all configuration from a methodology's manifest.yaml file. + This dataclass is created after successful schema validation. + + Note: PhaseDefinition represents the static manifest structure, while + Phase (from protocols.py) represents runtime state with status tracking. + Conversion between these types occurs during plugin loading (Story 1.4). + + Attributes: + name: Unique identifier for the methodology (required) + version: Semantic version string (required) + entry_point: Module.ClassName for the runner (required) + phases: List of phase definitions (required) + description: Human-readable description (optional) + author: Author name or organization (optional) + checkpoints: List of checkpoint definitions (optional) + artifacts: List of artifact definitions (optional) + min_auto_claude_version: Minimum Auto Claude version required (optional) + runtime: Runtime requirement e.g. "python3.12" (optional) + complexity_levels: Supported complexity levels (optional) + execution_modes: Supported execution modes (optional) + license: License identifier e.g. "MIT" (optional) + homepage: Project homepage URL (optional) + repository: Source repository URL (optional) + keywords: List of keywords for discovery (optional) + """ + + name: str + version: str + entry_point: str + phases: list[PhaseDefinition] + description: str = "" + author: str = "" + checkpoints: list[CheckpointDefinition] = field(default_factory=list) + artifacts: list[ArtifactDefinition] = field(default_factory=list) + min_auto_claude_version: str = "" + runtime: str = "" + complexity_levels: list[str] = field(default_factory=list) + execution_modes: list[str] = field(default_factory=list) + license: str = "" + homepage: str = "" + repository: str = "" + keywords: list[str] = field(default_factory=list) + + +def _parse_phases(phases_data: list[dict[str, Any]]) -> list[PhaseDefinition]: + """Parse phase definitions from manifest data. + + Args: + phases_data: List of phase dictionaries from YAML + + Returns: + List of PhaseDefinition dataclass instances + """ + return [ + PhaseDefinition( + id=phase["id"], + name=phase["name"], + description=phase.get("description", ""), + artifacts=phase.get("artifacts", []), + ) + for phase in phases_data + ] + + +def _parse_checkpoints( + checkpoints_data: list[dict[str, Any]] | None, +) -> list[CheckpointDefinition]: + """Parse checkpoint definitions from manifest data. + + Args: + checkpoints_data: List of checkpoint dictionaries from YAML, or None + + Returns: + List of CheckpointDefinition dataclass instances + """ + if not checkpoints_data: + return [] + return [ + CheckpointDefinition( + id=cp["id"], + phase=cp["phase"], + description=cp.get("description", ""), + ) + for cp in checkpoints_data + ] + + +def _parse_artifacts( + artifacts_data: list[dict[str, Any]] | None, +) -> list[ArtifactDefinition]: + """Parse artifact definitions from manifest data. + + Args: + artifacts_data: List of artifact dictionaries from YAML, or None + + Returns: + List of ArtifactDefinition dataclass instances + """ + if not artifacts_data: + return [] + return [ + ArtifactDefinition( + id=art["id"], + name=art["name"], + type=art.get("type", "markdown"), + path=art.get("path", ""), + ) + for art in artifacts_data + ] + + +def _validate_duplicate_ids( + items: list[dict[str, Any]] | None, + item_type: str, +) -> list[str]: + """Check for duplicate IDs in a list of items. + + Args: + items: List of item dictionaries with 'id' fields + item_type: Type name for error messages (e.g., "phase", "checkpoint") + + Returns: + List of error messages for duplicate IDs + """ + if not items: + return [] + + # Skip if items is not a list (schema validation will catch this) + if not isinstance(items, list): + return [] + + errors: list[str] = [] + seen_ids: set[str] = set() + + for item in items: + # Skip if item is not a dict (schema validation will catch this) + if not isinstance(item, dict): + continue + item_id = item.get("id") + if item_id: + if item_id in seen_ids: + errors.append(f"Duplicate {item_type} ID: '{item_id}'") + else: + seen_ids.add(item_id) + + return errors + + +def _validate_checkpoint_references( + checkpoints: list[dict[str, Any]] | None, + phases: list[dict[str, Any]], +) -> list[str]: + """Validate that checkpoint phase references exist. + + Args: + checkpoints: List of checkpoint dictionaries + phases: List of phase dictionaries + + Returns: + List of error messages for invalid references + """ + if not checkpoints: + return [] + + errors: list[str] = [] + phase_ids = {phase.get("id") for phase in phases if phase.get("id")} + + for checkpoint in checkpoints: + checkpoint_id = checkpoint.get("id", "unknown") + phase_ref = checkpoint.get("phase") + if phase_ref and phase_ref not in phase_ids: + errors.append( + f"Checkpoint '{checkpoint_id}' references non-existent phase: '{phase_ref}'" + ) + + return errors + + +def _validate_manifest(manifest_data: dict[str, Any], path: Path) -> list[str]: + """Validate manifest data against JSON Schema and semantic rules. + + Performs: + 1. JSON Schema validation (structure, types, formats) + 2. Duplicate ID validation (phases, checkpoints, artifacts) + 3. Cross-reference validation (checkpoint → phase references) + + Args: + manifest_data: Parsed YAML data dictionary + path: Path to the manifest file (for error messages) + + Returns: + List of validation error messages (empty if valid) + """ + # Load the JSON Schema + schema_path = Path(__file__).parent / "schemas" / "manifest.schema.json" + if not schema_path.exists(): + return [f"Schema file not found: {schema_path}"] + + try: + with open(schema_path, encoding="utf-8") as f: + schema = json.load(f) + except json.JSONDecodeError as e: + return [f"Invalid schema JSON: {e}"] + + # Collect all validation errors + errors: list[str] = [] + + # 1. JSON Schema validation with format checking (for URI validation) + validator = jsonschema.Draft7Validator(schema, format_checker=_format_checker) + + for error in validator.iter_errors(manifest_data): + # Format error message with path and description + if error.path: + field_path = ".".join(str(p) for p in error.path) + errors.append(f"{field_path}: {error.message}") + else: + errors.append(error.message) + + # 2. Duplicate ID validation + phases = manifest_data.get("phases", []) + checkpoints = manifest_data.get("checkpoints") + artifacts = manifest_data.get("artifacts") + + errors.extend(_validate_duplicate_ids(phases, "phase")) + errors.extend(_validate_duplicate_ids(checkpoints, "checkpoint")) + errors.extend(_validate_duplicate_ids(artifacts, "artifact")) + + # 3. Cross-reference validation + errors.extend(_validate_checkpoint_references(checkpoints, phases)) + + return errors + + +def load_manifest(path: Path) -> MethodologyManifest: + """Load and validate a methodology manifest. + + Reads a manifest.yaml file, validates it against the JSON Schema, + and returns a MethodologyManifest dataclass with all fields populated. + + Args: + path: Path to the manifest.yaml file + + Returns: + MethodologyManifest dataclass with validated data + + Raises: + ManifestValidationError: If manifest is missing, malformed, or invalid + FileNotFoundError: If the manifest file doesn't exist + """ + if not path.exists(): + raise FileNotFoundError(f"Manifest file not found: {path}") + + # Read and parse YAML + try: + with open(path, encoding="utf-8") as f: + manifest_data = yaml.safe_load(f) + except yaml.YAMLError as e: + raise ManifestValidationError(path, [f"Invalid YAML: {e}"]) from e + + if not manifest_data or not isinstance(manifest_data, dict): + raise ManifestValidationError(path, ["Manifest must be a YAML object"]) + + # Validate against JSON Schema + errors = _validate_manifest(manifest_data, path) + if errors: + raise ManifestValidationError(path, errors) + + # Parse and create dataclass + return MethodologyManifest( + name=manifest_data["name"], + version=manifest_data["version"], + entry_point=manifest_data["entry_point"], + phases=_parse_phases(manifest_data["phases"]), + description=manifest_data.get("description", ""), + author=manifest_data.get("author", ""), + checkpoints=_parse_checkpoints(manifest_data.get("checkpoints")), + artifacts=_parse_artifacts(manifest_data.get("artifacts")), + min_auto_claude_version=manifest_data.get("min_auto_claude_version", ""), + runtime=manifest_data.get("runtime", ""), + complexity_levels=manifest_data.get("complexity_levels", []), + execution_modes=manifest_data.get("execution_modes", []), + license=manifest_data.get("license", ""), + homepage=manifest_data.get("homepage", ""), + repository=manifest_data.get("repository", ""), + keywords=manifest_data.get("keywords", []), + ) diff --git a/apps/backend/methodologies/schemas/manifest.schema.json b/apps/backend/methodologies/schemas/manifest.schema.json new file mode 100644 index 00000000..a2fd2093 --- /dev/null +++ b/apps/backend/methodologies/schemas/manifest.schema.json @@ -0,0 +1,189 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Methodology Manifest", + "description": "Schema for methodology plugin manifest.yaml files", + "type": "object", + "required": ["name", "version", "entry_point", "phases"], + "properties": { + "name": { + "type": "string", + "description": "Unique identifier for the methodology plugin (kebab-case recommended)", + "pattern": "^[a-z][a-z0-9-]*$", + "minLength": 1, + "maxLength": 64 + }, + "version": { + "type": "string", + "description": "Semantic version string (e.g., '1.0.0')", + "pattern": "^\\d+\\.\\d+\\.\\d+(-[a-zA-Z0-9.]+)?$" + }, + "entry_point": { + "type": "string", + "description": "Module.ClassName path to the MethodologyRunner implementation", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*(\\.[a-zA-Z_][a-zA-Z0-9_]*)+$" + }, + "phases": { + "type": "array", + "description": "List of phase definitions for the methodology pipeline", + "minItems": 1, + "items": { + "$ref": "#/definitions/phase" + } + }, + "description": { + "type": "string", + "description": "Human-readable description of the methodology" + }, + "author": { + "type": "string", + "description": "Author name or organization" + }, + "checkpoints": { + "type": "array", + "description": "List of checkpoint definitions for Semi-Auto pause points", + "items": { + "$ref": "#/definitions/checkpoint" + } + }, + "artifacts": { + "type": "array", + "description": "List of artifact definitions for methodology outputs", + "items": { + "$ref": "#/definitions/artifact" + } + }, + "min_auto_claude_version": { + "type": "string", + "description": "Minimum Auto Claude version required (optional, for future compatibility checks)", + "pattern": "^\\d+\\.\\d+\\.\\d+(-[a-zA-Z0-9.]+)?$" + }, + "runtime": { + "type": "string", + "description": "Runtime requirement (optional, e.g., 'python3.12')", + "pattern": "^[a-z][a-z0-9._-]*$" + }, + "complexity_levels": { + "type": "array", + "description": "Supported complexity levels (optional)", + "items": { + "type": "string", + "enum": ["quick", "standard", "complex"] + }, + "minItems": 1 + }, + "execution_modes": { + "type": "array", + "description": "Supported execution modes (optional)", + "items": { + "type": "string", + "enum": ["full_auto", "semi_auto"] + }, + "minItems": 1 + }, + "license": { + "type": "string", + "description": "License identifier (optional, e.g., 'MIT', 'Apache-2.0')" + }, + "homepage": { + "type": "string", + "description": "Project homepage URL (optional)", + "format": "uri" + }, + "repository": { + "type": "string", + "description": "Source repository URL (optional)", + "format": "uri" + }, + "keywords": { + "type": "array", + "description": "Keywords for discovery (optional)", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "additionalProperties": false, + "definitions": { + "phase": { + "type": "object", + "description": "A phase definition in the methodology pipeline", + "required": ["id", "name"], + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the phase", + "pattern": "^[a-z][a-z0-9_-]*$", + "minLength": 1 + }, + "name": { + "type": "string", + "description": "Human-readable name for display", + "minLength": 1 + }, + "description": { + "type": "string", + "description": "Detailed description of what this phase does" + }, + "artifacts": { + "type": "array", + "description": "List of artifact IDs produced by this phase", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "checkpoint": { + "type": "object", + "description": "A checkpoint definition for Semi-Auto pause points", + "required": ["id", "phase"], + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the checkpoint", + "pattern": "^[a-z][a-z0-9_-]*$", + "minLength": 1 + }, + "phase": { + "type": "string", + "description": "ID of the phase this checkpoint follows" + }, + "description": { + "type": "string", + "description": "What the user should review at this checkpoint" + } + }, + "additionalProperties": false + }, + "artifact": { + "type": "object", + "description": "An artifact definition for methodology outputs", + "required": ["id", "name"], + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the artifact", + "pattern": "^[a-z][a-z0-9_-]*$", + "minLength": 1 + }, + "name": { + "type": "string", + "description": "Human-readable name for display", + "minLength": 1 + }, + "type": { + "type": "string", + "description": "Type of artifact content", + "enum": ["markdown", "json", "yaml", "text", "html"] + }, + "path": { + "type": "string", + "description": "Path to the artifact file relative to spec directory" + } + }, + "additionalProperties": false + } + } +} diff --git a/apps/backend/requirements.txt b/apps/backend/requirements.txt index 13a9e1fb..3f6f25d6 100644 --- a/apps/backend/requirements.txt +++ b/apps/backend/requirements.txt @@ -20,5 +20,11 @@ google-generativeai>=0.8.0 # Pydantic for structured output schemas pydantic>=2.0.0 +# YAML parsing for plugin manifests and registry +pyyaml>=6.0.0 + +# JSON Schema validation for manifest.yaml +jsonschema>=4.0.0 + # Error tracking (optional - requires SENTRY_DSN environment variable) sentry-sdk>=2.0.0 diff --git a/tests/methodologies/test_manifest.py b/tests/methodologies/test_manifest.py new file mode 100644 index 00000000..83cec2e3 --- /dev/null +++ b/tests/methodologies/test_manifest.py @@ -0,0 +1,1141 @@ +"""Unit tests for the manifest loader and schema validation. + +Tests for apps/backend/methodologies/manifest.py covering: +- Valid manifest loading +- Missing required fields error +- Invalid field types error +- Optional fields handling +""" + +from pathlib import Path +from typing import Any + +import pytest +import yaml + +from apps.backend.methodologies.exceptions import ManifestValidationError +from apps.backend.methodologies.manifest import ( + ArtifactDefinition, + CheckpointDefinition, + MethodologyManifest, + PhaseDefinition, + load_manifest, +) + + +@pytest.fixture +def valid_manifest_data() -> dict[str, Any]: + """Return a valid manifest data dictionary.""" + return { + "name": "test-methodology", + "version": "1.0.0", + "description": "Test methodology for unit tests", + "author": "Test Author", + "entry_point": "methodology.TestRunner", + "phases": [ + { + "id": "discovery", + "name": "Discovery", + "description": "Gather project context", + "artifacts": ["context.json"], + }, + { + "id": "planning", + "name": "Planning", + "description": "Create implementation plan", + }, + ], + "checkpoints": [ + { + "id": "after_planning", + "phase": "planning", + "description": "Review implementation plan", + } + ], + "artifacts": [ + { + "id": "spec", + "name": "Specification", + "type": "markdown", + "path": "spec.md", + } + ], + } + + +@pytest.fixture +def minimal_manifest_data() -> dict[str, Any]: + """Return a minimal valid manifest with only required fields.""" + return { + "name": "minimal-test", + "version": "0.1.0", + "entry_point": "module.Runner", + "phases": [ + { + "id": "main", + "name": "Main Phase", + } + ], + } + + +@pytest.fixture +def manifest_file(tmp_path: Path, valid_manifest_data: dict[str, Any]) -> Path: + """Create a valid manifest file in a temp directory.""" + manifest_path = tmp_path / "manifest.yaml" + with open(manifest_path, "w", encoding="utf-8") as f: + yaml.dump(valid_manifest_data, f) + return manifest_path + + +@pytest.fixture +def minimal_manifest_file( + tmp_path: Path, minimal_manifest_data: dict[str, Any] +) -> Path: + """Create a minimal manifest file in a temp directory.""" + manifest_path = tmp_path / "manifest.yaml" + with open(manifest_path, "w", encoding="utf-8") as f: + yaml.dump(minimal_manifest_data, f) + return manifest_path + + +class TestLoadManifestValid: + """Tests for loading valid manifests.""" + + def test_load_valid_manifest_returns_dataclass( + self, manifest_file: Path + ) -> None: + """Test that a valid manifest returns a MethodologyManifest.""" + result = load_manifest(manifest_file) + + assert isinstance(result, MethodologyManifest) + + def test_load_valid_manifest_required_fields( + self, manifest_file: Path + ) -> None: + """Test that required fields are correctly populated.""" + result = load_manifest(manifest_file) + + assert result.name == "test-methodology" + assert result.version == "1.0.0" + assert result.entry_point == "methodology.TestRunner" + assert len(result.phases) == 2 + + def test_load_valid_manifest_optional_fields( + self, manifest_file: Path + ) -> None: + """Test that optional fields are correctly populated.""" + result = load_manifest(manifest_file) + + assert result.description == "Test methodology for unit tests" + assert result.author == "Test Author" + assert len(result.checkpoints) == 1 + assert len(result.artifacts) == 1 + + def test_load_valid_manifest_phase_definitions( + self, manifest_file: Path + ) -> None: + """Test that phase definitions are correctly parsed.""" + result = load_manifest(manifest_file) + + discovery = result.phases[0] + assert isinstance(discovery, PhaseDefinition) + assert discovery.id == "discovery" + assert discovery.name == "Discovery" + assert discovery.description == "Gather project context" + assert discovery.artifacts == ["context.json"] + + planning = result.phases[1] + assert planning.id == "planning" + assert planning.artifacts == [] # Default empty list + + def test_load_valid_manifest_checkpoint_definitions( + self, manifest_file: Path + ) -> None: + """Test that checkpoint definitions are correctly parsed.""" + result = load_manifest(manifest_file) + + checkpoint = result.checkpoints[0] + assert isinstance(checkpoint, CheckpointDefinition) + assert checkpoint.id == "after_planning" + assert checkpoint.phase == "planning" + assert checkpoint.description == "Review implementation plan" + + def test_load_valid_manifest_artifact_definitions( + self, manifest_file: Path + ) -> None: + """Test that artifact definitions are correctly parsed.""" + result = load_manifest(manifest_file) + + artifact = result.artifacts[0] + assert isinstance(artifact, ArtifactDefinition) + assert artifact.id == "spec" + assert artifact.name == "Specification" + assert artifact.type == "markdown" + assert artifact.path == "spec.md" + + def test_load_minimal_manifest(self, minimal_manifest_file: Path) -> None: + """Test loading a manifest with only required fields.""" + result = load_manifest(minimal_manifest_file) + + assert result.name == "minimal-test" + assert result.version == "0.1.0" + assert result.entry_point == "module.Runner" + assert len(result.phases) == 1 + assert result.description == "" # Default empty string + assert result.author == "" + assert result.checkpoints == [] # Default empty list + assert result.artifacts == [] + + +class TestLoadManifestMissingRequiredFields: + """Tests for error handling when required fields are missing.""" + + def test_missing_name_field(self, tmp_path: Path) -> None: + """Test that missing 'name' field raises ManifestValidationError.""" + manifest_path = tmp_path / "manifest.yaml" + data = { + "version": "1.0.0", + "entry_point": "module.Runner", + "phases": [{"id": "main", "name": "Main"}], + } + with open(manifest_path, "w", encoding="utf-8") as f: + yaml.dump(data, f) + + with pytest.raises(ManifestValidationError) as exc_info: + load_manifest(manifest_path) + + assert exc_info.value.path == manifest_path + assert any("name" in error for error in exc_info.value.errors) + + def test_missing_version_field(self, tmp_path: Path) -> None: + """Test that missing 'version' field raises ManifestValidationError.""" + manifest_path = tmp_path / "manifest.yaml" + data = { + "name": "test-plugin", + "entry_point": "module.Runner", + "phases": [{"id": "main", "name": "Main"}], + } + with open(manifest_path, "w", encoding="utf-8") as f: + yaml.dump(data, f) + + with pytest.raises(ManifestValidationError) as exc_info: + load_manifest(manifest_path) + + assert exc_info.value.path == manifest_path + assert any("version" in error for error in exc_info.value.errors) + + def test_missing_entry_point_field(self, tmp_path: Path) -> None: + """Test that missing 'entry_point' field raises ManifestValidationError.""" + manifest_path = tmp_path / "manifest.yaml" + data = { + "name": "test-plugin", + "version": "1.0.0", + "phases": [{"id": "main", "name": "Main"}], + } + with open(manifest_path, "w", encoding="utf-8") as f: + yaml.dump(data, f) + + with pytest.raises(ManifestValidationError) as exc_info: + load_manifest(manifest_path) + + assert exc_info.value.path == manifest_path + assert any("entry_point" in error for error in exc_info.value.errors) + + def test_missing_phases_field(self, tmp_path: Path) -> None: + """Test that missing 'phases' field raises ManifestValidationError.""" + manifest_path = tmp_path / "manifest.yaml" + data = { + "name": "test-plugin", + "version": "1.0.0", + "entry_point": "module.Runner", + } + with open(manifest_path, "w", encoding="utf-8") as f: + yaml.dump(data, f) + + with pytest.raises(ManifestValidationError) as exc_info: + load_manifest(manifest_path) + + assert exc_info.value.path == manifest_path + assert any("phases" in error for error in exc_info.value.errors) + + def test_multiple_missing_fields(self, tmp_path: Path) -> None: + """Test that multiple missing fields are all reported.""" + manifest_path = tmp_path / "manifest.yaml" + data = { + "description": "Missing required fields", + } + with open(manifest_path, "w", encoding="utf-8") as f: + yaml.dump(data, f) + + with pytest.raises(ManifestValidationError) as exc_info: + load_manifest(manifest_path) + + assert exc_info.value.path == manifest_path + # Should have multiple errors for name, version, entry_point, phases + assert len(exc_info.value.errors) >= 4 + + +class TestLoadManifestInvalidFieldTypes: + """Tests for error handling when field types are invalid.""" + + def test_invalid_name_type(self, tmp_path: Path) -> None: + """Test that non-string 'name' raises ManifestValidationError.""" + manifest_path = tmp_path / "manifest.yaml" + data = { + "name": 123, # Should be string + "version": "1.0.0", + "entry_point": "module.Runner", + "phases": [{"id": "main", "name": "Main"}], + } + with open(manifest_path, "w", encoding="utf-8") as f: + yaml.dump(data, f) + + with pytest.raises(ManifestValidationError) as exc_info: + load_manifest(manifest_path) + + assert exc_info.value.path == manifest_path + assert any("name" in error.lower() for error in exc_info.value.errors) + + def test_invalid_version_format(self, tmp_path: Path) -> None: + """Test that invalid version format raises ManifestValidationError.""" + manifest_path = tmp_path / "manifest.yaml" + data = { + "name": "test-plugin", + "version": "not-a-version", # Should be semver + "entry_point": "module.Runner", + "phases": [{"id": "main", "name": "Main"}], + } + with open(manifest_path, "w", encoding="utf-8") as f: + yaml.dump(data, f) + + with pytest.raises(ManifestValidationError) as exc_info: + load_manifest(manifest_path) + + assert exc_info.value.path == manifest_path + assert any("version" in error.lower() for error in exc_info.value.errors) + + def test_invalid_entry_point_format(self, tmp_path: Path) -> None: + """Test that invalid entry_point format raises ManifestValidationError.""" + manifest_path = tmp_path / "manifest.yaml" + data = { + "name": "test-plugin", + "version": "1.0.0", + "entry_point": "invalid", # Should be module.ClassName + "phases": [{"id": "main", "name": "Main"}], + } + with open(manifest_path, "w", encoding="utf-8") as f: + yaml.dump(data, f) + + with pytest.raises(ManifestValidationError) as exc_info: + load_manifest(manifest_path) + + assert exc_info.value.path == manifest_path + assert any("entry_point" in error.lower() for error in exc_info.value.errors) + + def test_invalid_phases_type(self, tmp_path: Path) -> None: + """Test that non-array 'phases' raises ManifestValidationError.""" + manifest_path = tmp_path / "manifest.yaml" + data = { + "name": "test-plugin", + "version": "1.0.0", + "entry_point": "module.Runner", + "phases": "not-an-array", # Should be array + } + with open(manifest_path, "w", encoding="utf-8") as f: + yaml.dump(data, f) + + with pytest.raises(ManifestValidationError) as exc_info: + load_manifest(manifest_path) + + assert exc_info.value.path == manifest_path + assert any("phases" in error.lower() for error in exc_info.value.errors) + + def test_empty_phases_array(self, tmp_path: Path) -> None: + """Test that empty 'phases' array raises ManifestValidationError.""" + manifest_path = tmp_path / "manifest.yaml" + data = { + "name": "test-plugin", + "version": "1.0.0", + "entry_point": "module.Runner", + "phases": [], # Should have at least one phase + } + with open(manifest_path, "w", encoding="utf-8") as f: + yaml.dump(data, f) + + with pytest.raises(ManifestValidationError) as exc_info: + load_manifest(manifest_path) + + assert exc_info.value.path == manifest_path + assert any("phases" in error.lower() for error in exc_info.value.errors) + + def test_phase_missing_required_fields(self, tmp_path: Path) -> None: + """Test that phase missing 'id' or 'name' raises ManifestValidationError.""" + manifest_path = tmp_path / "manifest.yaml" + data = { + "name": "test-plugin", + "version": "1.0.0", + "entry_point": "module.Runner", + "phases": [{"description": "Missing id and name"}], # Missing required + } + with open(manifest_path, "w", encoding="utf-8") as f: + yaml.dump(data, f) + + with pytest.raises(ManifestValidationError) as exc_info: + load_manifest(manifest_path) + + assert exc_info.value.path == manifest_path + assert len(exc_info.value.errors) > 0 + + def test_checkpoint_missing_required_fields(self, tmp_path: Path) -> None: + """Test that checkpoint missing required fields raises ManifestValidationError.""" + manifest_path = tmp_path / "manifest.yaml" + data = { + "name": "test-plugin", + "version": "1.0.0", + "entry_point": "module.Runner", + "phases": [{"id": "main", "name": "Main"}], + "checkpoints": [{"description": "Missing id and phase"}], + } + with open(manifest_path, "w", encoding="utf-8") as f: + yaml.dump(data, f) + + with pytest.raises(ManifestValidationError) as exc_info: + load_manifest(manifest_path) + + assert exc_info.value.path == manifest_path + assert len(exc_info.value.errors) > 0 + + +class TestLoadManifestOptionalFields: + """Tests for handling of optional fields.""" + + def test_optional_description_not_present( + self, minimal_manifest_file: Path + ) -> None: + """Test that missing optional 'description' defaults to empty string.""" + result = load_manifest(minimal_manifest_file) + assert result.description == "" + + def test_optional_author_not_present( + self, minimal_manifest_file: Path + ) -> None: + """Test that missing optional 'author' defaults to empty string.""" + result = load_manifest(minimal_manifest_file) + assert result.author == "" + + def test_optional_checkpoints_not_present( + self, minimal_manifest_file: Path + ) -> None: + """Test that missing optional 'checkpoints' defaults to empty list.""" + result = load_manifest(minimal_manifest_file) + assert result.checkpoints == [] + + def test_optional_artifacts_not_present( + self, minimal_manifest_file: Path + ) -> None: + """Test that missing optional 'artifacts' defaults to empty list.""" + result = load_manifest(minimal_manifest_file) + assert result.artifacts == [] + + def test_phase_optional_description(self, tmp_path: Path) -> None: + """Test that phase without 'description' defaults to empty string.""" + manifest_path = tmp_path / "manifest.yaml" + data = { + "name": "test-plugin", + "version": "1.0.0", + "entry_point": "module.Runner", + "phases": [{"id": "main", "name": "Main"}], # No description + } + with open(manifest_path, "w", encoding="utf-8") as f: + yaml.dump(data, f) + + result = load_manifest(manifest_path) + assert result.phases[0].description == "" + + def test_phase_optional_artifacts(self, tmp_path: Path) -> None: + """Test that phase without 'artifacts' defaults to empty list.""" + manifest_path = tmp_path / "manifest.yaml" + data = { + "name": "test-plugin", + "version": "1.0.0", + "entry_point": "module.Runner", + "phases": [{"id": "main", "name": "Main"}], # No artifacts + } + with open(manifest_path, "w", encoding="utf-8") as f: + yaml.dump(data, f) + + result = load_manifest(manifest_path) + assert result.phases[0].artifacts == [] + + def test_artifact_optional_type_default(self, tmp_path: Path) -> None: + """Test that artifact without 'type' defaults to 'markdown'.""" + manifest_path = tmp_path / "manifest.yaml" + data = { + "name": "test-plugin", + "version": "1.0.0", + "entry_point": "module.Runner", + "phases": [{"id": "main", "name": "Main"}], + "artifacts": [{"id": "doc", "name": "Document"}], # No type + } + with open(manifest_path, "w", encoding="utf-8") as f: + yaml.dump(data, f) + + result = load_manifest(manifest_path) + assert result.artifacts[0].type == "markdown" + + +class TestLoadManifestErrorMessages: + """Tests for error message quality.""" + + def test_error_includes_manifest_path(self, tmp_path: Path) -> None: + """Test that error message includes the manifest file path.""" + manifest_path = tmp_path / "manifest.yaml" + data = {"description": "Missing required fields"} + with open(manifest_path, "w", encoding="utf-8") as f: + yaml.dump(data, f) + + with pytest.raises(ManifestValidationError) as exc_info: + load_manifest(manifest_path) + + assert str(manifest_path) in str(exc_info.value) + assert exc_info.value.path == manifest_path + + def test_error_lists_all_missing_fields(self, tmp_path: Path) -> None: + """Test that all missing fields are listed in error.""" + manifest_path = tmp_path / "manifest.yaml" + # Use a dict with some valid content so it passes the "is dict" check + # but fails schema validation for missing required fields + data = {"description": "Missing all required fields"} + with open(manifest_path, "w", encoding="utf-8") as f: + yaml.dump(data, f) + + with pytest.raises(ManifestValidationError) as exc_info: + load_manifest(manifest_path) + + # Should report name, version, entry_point, phases as missing + assert len(exc_info.value.errors) >= 4 + + +class TestLoadManifestArchitectureFields: + """Tests for architecture-defined optional fields.""" + + def test_manifest_with_all_architecture_fields(self, tmp_path: Path) -> None: + """Test loading manifest with all architecture-defined optional fields.""" + manifest_path = tmp_path / "manifest.yaml" + data = { + "name": "full-featured", + "version": "1.0.0", + "entry_point": "module.Runner", + "phases": [{"id": "main", "name": "Main"}], + "min_auto_claude_version": "3.0.0", + "runtime": "python3.12", + "complexity_levels": ["quick", "standard", "complex"], + "execution_modes": ["full_auto", "semi_auto"], + } + with open(manifest_path, "w", encoding="utf-8") as f: + yaml.dump(data, f) + + result = load_manifest(manifest_path) + + assert result.min_auto_claude_version == "3.0.0" + assert result.runtime == "python3.12" + assert result.complexity_levels == ["quick", "standard", "complex"] + assert result.execution_modes == ["full_auto", "semi_auto"] + + def test_architecture_fields_default_when_not_present( + self, minimal_manifest_file: Path + ) -> None: + """Test that architecture fields default to empty when not present.""" + result = load_manifest(minimal_manifest_file) + + assert result.min_auto_claude_version == "" + assert result.runtime == "" + assert result.complexity_levels == [] + assert result.execution_modes == [] + + def test_invalid_complexity_level_rejected(self, tmp_path: Path) -> None: + """Test that invalid complexity level values are rejected.""" + manifest_path = tmp_path / "manifest.yaml" + data = { + "name": "test-plugin", + "version": "1.0.0", + "entry_point": "module.Runner", + "phases": [{"id": "main", "name": "Main"}], + "complexity_levels": ["invalid_level"], # Not in enum + } + with open(manifest_path, "w", encoding="utf-8") as f: + yaml.dump(data, f) + + with pytest.raises(ManifestValidationError) as exc_info: + load_manifest(manifest_path) + + assert any("complexity_levels" in error.lower() for error in exc_info.value.errors) + + def test_invalid_execution_mode_rejected(self, tmp_path: Path) -> None: + """Test that invalid execution mode values are rejected.""" + manifest_path = tmp_path / "manifest.yaml" + data = { + "name": "test-plugin", + "version": "1.0.0", + "entry_point": "module.Runner", + "phases": [{"id": "main", "name": "Main"}], + "execution_modes": ["invalid_mode"], # Not in enum + } + with open(manifest_path, "w", encoding="utf-8") as f: + yaml.dump(data, f) + + with pytest.raises(ManifestValidationError) as exc_info: + load_manifest(manifest_path) + + assert any("execution_modes" in error.lower() for error in exc_info.value.errors) + + +class TestLoadManifestUTF8: + """Tests for UTF-8 and Unicode handling.""" + + def test_manifest_with_utf8_author(self, tmp_path: Path) -> None: + """Test loading manifest with UTF-8 author name.""" + manifest_path = tmp_path / "manifest.yaml" + data = { + "name": "test-plugin", + "version": "1.0.0", + "entry_point": "module.Runner", + "phases": [{"id": "main", "name": "Main"}], + "author": "André Mikalsen", + } + with open(manifest_path, "w", encoding="utf-8") as f: + yaml.dump(data, f, allow_unicode=True) + + result = load_manifest(manifest_path) + assert result.author == "André Mikalsen" + + def test_manifest_with_utf8_description(self, tmp_path: Path) -> None: + """Test loading manifest with UTF-8 description containing special chars.""" + manifest_path = tmp_path / "manifest.yaml" + data = { + "name": "test-plugin", + "version": "1.0.0", + "entry_point": "module.Runner", + "phases": [{"id": "main", "name": "Main"}], + "description": "Méthodologie für die Entwicklung — создание проектов", + } + with open(manifest_path, "w", encoding="utf-8") as f: + yaml.dump(data, f, allow_unicode=True) + + result = load_manifest(manifest_path) + assert "Méthodologie" in result.description + assert "создание" in result.description + + def test_manifest_with_utf8_keywords(self, tmp_path: Path) -> None: + """Test loading manifest with UTF-8 keywords.""" + manifest_path = tmp_path / "manifest.yaml" + data = { + "name": "test-plugin", + "version": "1.0.0", + "entry_point": "module.Runner", + "phases": [{"id": "main", "name": "Main"}], + "keywords": ["développement", "テスト", "测试"], + } + with open(manifest_path, "w", encoding="utf-8") as f: + yaml.dump(data, f, allow_unicode=True) + + result = load_manifest(manifest_path) + assert "développement" in result.keywords + assert "テスト" in result.keywords + assert "测试" in result.keywords + + def test_manifest_with_utf8_bom(self, tmp_path: Path) -> None: + """Test loading manifest with UTF-8 BOM marker.""" + manifest_path = tmp_path / "manifest.yaml" + content = """\ +name: test-plugin +version: "1.0.0" +entry_point: module.Runner +phases: + - id: main + name: Main +""" + # Write with UTF-8 BOM + with open(manifest_path, "wb") as f: + f.write(b"\xef\xbb\xbf") # UTF-8 BOM + f.write(content.encode("utf-8")) + + result = load_manifest(manifest_path) + assert result.name == "test-plugin" + + +class TestLoadManifestDuplicateIdValidation: + """Tests for duplicate ID validation.""" + + def test_duplicate_phase_ids_rejected(self, tmp_path: Path) -> None: + """Test that duplicate phase IDs are rejected.""" + manifest_path = tmp_path / "manifest.yaml" + data = { + "name": "test-plugin", + "version": "1.0.0", + "entry_point": "module.Runner", + "phases": [ + {"id": "main", "name": "Main Phase 1"}, + {"id": "main", "name": "Main Phase 2"}, # Duplicate ID + ], + } + with open(manifest_path, "w", encoding="utf-8") as f: + yaml.dump(data, f) + + with pytest.raises(ManifestValidationError) as exc_info: + load_manifest(manifest_path) + + assert any("duplicate" in error.lower() and "phase" in error.lower() + for error in exc_info.value.errors) + + def test_duplicate_checkpoint_ids_rejected(self, tmp_path: Path) -> None: + """Test that duplicate checkpoint IDs are rejected.""" + manifest_path = tmp_path / "manifest.yaml" + data = { + "name": "test-plugin", + "version": "1.0.0", + "entry_point": "module.Runner", + "phases": [{"id": "main", "name": "Main"}], + "checkpoints": [ + {"id": "cp1", "phase": "main", "description": "First"}, + {"id": "cp1", "phase": "main", "description": "Second"}, # Duplicate + ], + } + with open(manifest_path, "w", encoding="utf-8") as f: + yaml.dump(data, f) + + with pytest.raises(ManifestValidationError) as exc_info: + load_manifest(manifest_path) + + assert any("duplicate" in error.lower() and "checkpoint" in error.lower() + for error in exc_info.value.errors) + + def test_duplicate_artifact_ids_rejected(self, tmp_path: Path) -> None: + """Test that duplicate artifact IDs are rejected.""" + manifest_path = tmp_path / "manifest.yaml" + data = { + "name": "test-plugin", + "version": "1.0.0", + "entry_point": "module.Runner", + "phases": [{"id": "main", "name": "Main"}], + "artifacts": [ + {"id": "doc", "name": "Document 1"}, + {"id": "doc", "name": "Document 2"}, # Duplicate + ], + } + with open(manifest_path, "w", encoding="utf-8") as f: + yaml.dump(data, f) + + with pytest.raises(ManifestValidationError) as exc_info: + load_manifest(manifest_path) + + assert any("duplicate" in error.lower() and "artifact" in error.lower() + for error in exc_info.value.errors) + + def test_unique_ids_across_types_allowed(self, tmp_path: Path) -> None: + """Test that same ID in different types is allowed (phase, checkpoint, artifact).""" + manifest_path = tmp_path / "manifest.yaml" + data = { + "name": "test-plugin", + "version": "1.0.0", + "entry_point": "module.Runner", + "phases": [{"id": "main", "name": "Main Phase"}], + "checkpoints": [{"id": "main", "phase": "main"}], # Same ID, different type + "artifacts": [{"id": "main", "name": "Main Doc"}], # Same ID, different type + } + with open(manifest_path, "w", encoding="utf-8") as f: + yaml.dump(data, f) + + result = load_manifest(manifest_path) + assert result.phases[0].id == "main" + assert result.checkpoints[0].id == "main" + assert result.artifacts[0].id == "main" + + +class TestLoadManifestCrossReferenceValidation: + """Tests for cross-reference validation.""" + + def test_checkpoint_references_nonexistent_phase(self, tmp_path: Path) -> None: + """Test that checkpoint referencing non-existent phase is rejected.""" + manifest_path = tmp_path / "manifest.yaml" + data = { + "name": "test-plugin", + "version": "1.0.0", + "entry_point": "module.Runner", + "phases": [{"id": "main", "name": "Main"}], + "checkpoints": [ + {"id": "cp1", "phase": "nonexistent_phase"} # Invalid reference + ], + } + with open(manifest_path, "w", encoding="utf-8") as f: + yaml.dump(data, f) + + with pytest.raises(ManifestValidationError) as exc_info: + load_manifest(manifest_path) + + assert any("nonexistent_phase" in error for error in exc_info.value.errors) + assert any("cp1" in error for error in exc_info.value.errors) + + def test_checkpoint_references_valid_phase(self, tmp_path: Path) -> None: + """Test that checkpoint referencing valid phase is accepted.""" + manifest_path = tmp_path / "manifest.yaml" + data = { + "name": "test-plugin", + "version": "1.0.0", + "entry_point": "module.Runner", + "phases": [ + {"id": "discovery", "name": "Discovery"}, + {"id": "planning", "name": "Planning"}, + ], + "checkpoints": [ + {"id": "after_discovery", "phase": "discovery"}, + {"id": "after_planning", "phase": "planning"}, + ], + } + with open(manifest_path, "w", encoding="utf-8") as f: + yaml.dump(data, f) + + result = load_manifest(manifest_path) + assert len(result.checkpoints) == 2 + assert result.checkpoints[0].phase == "discovery" + assert result.checkpoints[1].phase == "planning" + + +class TestLoadManifestURIValidation: + """Tests for URI format validation.""" + + def test_valid_homepage_uri(self, tmp_path: Path) -> None: + """Test that valid homepage URI is accepted.""" + manifest_path = tmp_path / "manifest.yaml" + data = { + "name": "test-plugin", + "version": "1.0.0", + "entry_point": "module.Runner", + "phases": [{"id": "main", "name": "Main"}], + "homepage": "https://example.com/project", + } + with open(manifest_path, "w", encoding="utf-8") as f: + yaml.dump(data, f) + + result = load_manifest(manifest_path) + assert result.homepage == "https://example.com/project" + + def test_valid_repository_uri(self, tmp_path: Path) -> None: + """Test that valid repository URI is accepted.""" + manifest_path = tmp_path / "manifest.yaml" + data = { + "name": "test-plugin", + "version": "1.0.0", + "entry_point": "module.Runner", + "phases": [{"id": "main", "name": "Main"}], + "repository": "https://github.com/user/repo", + } + with open(manifest_path, "w", encoding="utf-8") as f: + yaml.dump(data, f) + + result = load_manifest(manifest_path) + assert result.repository == "https://github.com/user/repo" + + def test_invalid_homepage_uri_rejected(self, tmp_path: Path) -> None: + """Test that invalid homepage URI is rejected.""" + manifest_path = tmp_path / "manifest.yaml" + data = { + "name": "test-plugin", + "version": "1.0.0", + "entry_point": "module.Runner", + "phases": [{"id": "main", "name": "Main"}], + "homepage": "not-a-valid-uri", # Invalid URI + } + with open(manifest_path, "w", encoding="utf-8") as f: + yaml.dump(data, f) + + with pytest.raises(ManifestValidationError) as exc_info: + load_manifest(manifest_path) + + assert any("homepage" in error.lower() for error in exc_info.value.errors) + + def test_invalid_repository_uri_rejected(self, tmp_path: Path) -> None: + """Test that invalid repository URI is rejected.""" + manifest_path = tmp_path / "manifest.yaml" + data = { + "name": "test-plugin", + "version": "1.0.0", + "entry_point": "module.Runner", + "phases": [{"id": "main", "name": "Main"}], + "repository": "also-not-valid", # Invalid URI + } + with open(manifest_path, "w", encoding="utf-8") as f: + yaml.dump(data, f) + + with pytest.raises(ManifestValidationError) as exc_info: + load_manifest(manifest_path) + + assert any("repository" in error.lower() for error in exc_info.value.errors) + + def test_non_http_uri_rejected(self, tmp_path: Path) -> None: + """Test that non-HTTP/HTTPS URIs are rejected.""" + manifest_path = tmp_path / "manifest.yaml" + data = { + "name": "test-plugin", + "version": "1.0.0", + "entry_point": "module.Runner", + "phases": [{"id": "main", "name": "Main"}], + "homepage": "ftp://example.com/project", # FTP not allowed + } + with open(manifest_path, "w", encoding="utf-8") as f: + yaml.dump(data, f) + + with pytest.raises(ManifestValidationError) as exc_info: + load_manifest(manifest_path) + + assert any("homepage" in error.lower() for error in exc_info.value.errors) + + def test_file_uri_rejected(self, tmp_path: Path) -> None: + """Test that file:// URIs are rejected.""" + manifest_path = tmp_path / "manifest.yaml" + data = { + "name": "test-plugin", + "version": "1.0.0", + "entry_point": "module.Runner", + "phases": [{"id": "main", "name": "Main"}], + "repository": "file:///path/to/repo", # file:// not allowed + } + with open(manifest_path, "w", encoding="utf-8") as f: + yaml.dump(data, f) + + with pytest.raises(ManifestValidationError) as exc_info: + load_manifest(manifest_path) + + assert any("repository" in error.lower() for error in exc_info.value.errors) + + +class TestLoadManifestMetadataFields: + """Tests for optional metadata fields (license, homepage, repository, keywords).""" + + def test_manifest_with_all_metadata_fields(self, tmp_path: Path) -> None: + """Test loading manifest with all metadata fields populated.""" + manifest_path = tmp_path / "manifest.yaml" + data = { + "name": "test-plugin", + "version": "1.0.0", + "entry_point": "module.Runner", + "phases": [{"id": "main", "name": "Main"}], + "license": "MIT", + "homepage": "https://example.com/project", + "repository": "https://github.com/user/repo", + "keywords": ["testing", "automation", "ci-cd"], + } + with open(manifest_path, "w", encoding="utf-8") as f: + yaml.dump(data, f) + + result = load_manifest(manifest_path) + + assert result.license == "MIT" + assert result.homepage == "https://example.com/project" + assert result.repository == "https://github.com/user/repo" + assert result.keywords == ["testing", "automation", "ci-cd"] + + def test_metadata_fields_default_when_not_present( + self, minimal_manifest_file: Path + ) -> None: + """Test that metadata fields default to empty when not present.""" + result = load_manifest(minimal_manifest_file) + + assert result.license == "" + assert result.homepage == "" + assert result.repository == "" + assert result.keywords == [] + + +class TestLoadManifestSchemaIntegration: + """Integration tests for the production schema file.""" + + def test_production_schema_file_exists_and_loads(self) -> None: + """Test that the production schema file exists and is valid JSON.""" + import json + from apps.backend.methodologies.manifest import Path as ManifestPath + + schema_path = ManifestPath(__file__).parent.parent.parent / \ + "apps" / "backend" / "methodologies" / "schemas" / "manifest.schema.json" + + assert schema_path.exists(), f"Schema file not found: {schema_path}" + + with open(schema_path, encoding="utf-8") as f: + schema = json.load(f) + + # Verify schema has expected structure + assert schema.get("type") == "object" + assert "required" in schema + assert "properties" in schema + assert set(schema["required"]) == {"name", "version", "entry_point", "phases"} + + def test_production_schema_validates_valid_manifest(self, tmp_path: Path) -> None: + """Test that a valid manifest passes validation with production schema.""" + manifest_path = tmp_path / "manifest.yaml" + data = { + "name": "production-test", + "version": "1.0.0", + "entry_point": "module.Runner", + "phases": [{"id": "main", "name": "Main Phase"}], + "description": "Testing production schema", + "author": "Test Author", + } + with open(manifest_path, "w", encoding="utf-8") as f: + yaml.dump(data, f) + + # Should not raise + result = load_manifest(manifest_path) + assert result.name == "production-test" + + +class TestLoadManifestSchemaErrors: + """Tests for schema-related error conditions.""" + + def test_malformed_schema_json_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Test that malformed schema JSON produces appropriate error.""" + # Create a malformed schema file + malformed_schema_path = tmp_path / "malformed.schema.json" + with open(malformed_schema_path, "w", encoding="utf-8") as f: + f.write("{invalid json syntax") + + # Patch the schema path + from apps.backend.methodologies import manifest as manifest_module + original_file = manifest_module.__file__ + + # Create the schemas directory structure + schemas_dir = tmp_path / "schemas" + schemas_dir.mkdir() + schema_file = schemas_dir / "manifest.schema.json" + with open(schema_file, "w", encoding="utf-8") as f: + f.write("{invalid json") + + # Create a valid manifest + manifest_path = tmp_path / "manifest.yaml" + data = { + "name": "test-plugin", + "version": "1.0.0", + "entry_point": "module.Runner", + "phases": [{"id": "main", "name": "Main"}], + } + with open(manifest_path, "w", encoding="utf-8") as f: + yaml.dump(data, f) + + # Monkeypatch __file__ to point to our temp directory + monkeypatch.setattr(manifest_module, "__file__", str(tmp_path / "manifest.py")) + + with pytest.raises(ManifestValidationError) as exc_info: + load_manifest(manifest_path) + + assert any("schema" in error.lower() and "json" in error.lower() + for error in exc_info.value.errors) + + def test_schema_file_not_found_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Test that missing schema file produces appropriate error.""" + from apps.backend.methodologies import manifest as manifest_module + + # Create a valid manifest + manifest_path = tmp_path / "manifest.yaml" + data = { + "name": "test-plugin", + "version": "1.0.0", + "entry_point": "module.Runner", + "phases": [{"id": "main", "name": "Main"}], + } + with open(manifest_path, "w", encoding="utf-8") as f: + yaml.dump(data, f) + + # Monkeypatch __file__ to point to a directory without schemas/ + monkeypatch.setattr(manifest_module, "__file__", str(tmp_path / "manifest.py")) + + with pytest.raises(ManifestValidationError) as exc_info: + load_manifest(manifest_path) + + assert any("schema" in error.lower() and "not found" in error.lower() + for error in exc_info.value.errors) + + +class TestLoadManifestEdgeCases: + """Tests for edge cases and special scenarios.""" + + def test_file_not_found(self, tmp_path: Path) -> None: + """Test that non-existent file raises FileNotFoundError.""" + non_existent = tmp_path / "does-not-exist.yaml" + + with pytest.raises(FileNotFoundError): + load_manifest(non_existent) + + def test_invalid_yaml_syntax(self, tmp_path: Path) -> None: + """Test that invalid YAML raises ManifestValidationError.""" + manifest_path = tmp_path / "manifest.yaml" + with open(manifest_path, "w", encoding="utf-8") as f: + f.write("invalid: yaml: syntax: [") + + with pytest.raises(ManifestValidationError) as exc_info: + load_manifest(manifest_path) + + assert any("yaml" in error.lower() for error in exc_info.value.errors) + + def test_empty_file(self, tmp_path: Path) -> None: + """Test that empty file raises ManifestValidationError.""" + manifest_path = tmp_path / "manifest.yaml" + manifest_path.touch() + + with pytest.raises(ManifestValidationError) as exc_info: + load_manifest(manifest_path) + + assert exc_info.value.path == manifest_path + + def test_manifest_with_extra_fields(self, tmp_path: Path) -> None: + """Test that extra unrecognized fields are rejected by schema.""" + manifest_path = tmp_path / "manifest.yaml" + data = { + "name": "test-plugin", + "version": "1.0.0", + "entry_point": "module.Runner", + "phases": [{"id": "main", "name": "Main"}], + "unknown_field": "should fail", # Extra field + } + with open(manifest_path, "w", encoding="utf-8") as f: + yaml.dump(data, f) + + with pytest.raises(ManifestValidationError) as exc_info: + load_manifest(manifest_path) + + assert len(exc_info.value.errors) > 0 + + def test_version_with_prerelease(self, tmp_path: Path) -> None: + """Test that version with prerelease identifier is valid.""" + manifest_path = tmp_path / "manifest.yaml" + data = { + "name": "test-plugin", + "version": "1.0.0-alpha.1", # Valid semver with prerelease + "entry_point": "module.Runner", + "phases": [{"id": "main", "name": "Main"}], + } + with open(manifest_path, "w", encoding="utf-8") as f: + yaml.dump(data, f) + + result = load_manifest(manifest_path) + assert result.version == "1.0.0-alpha.1" + + def test_name_kebab_case_validation(self, tmp_path: Path) -> None: + """Test that name must be kebab-case format.""" + manifest_path = tmp_path / "manifest.yaml" + data = { + "name": "InvalidName", # Should be kebab-case + "version": "1.0.0", + "entry_point": "module.Runner", + "phases": [{"id": "main", "name": "Main"}], + } + with open(manifest_path, "w", encoding="utf-8") as f: + yaml.dump(data, f) + + with pytest.raises(ManifestValidationError) as exc_info: + load_manifest(manifest_path) + + assert any("name" in error.lower() for error in exc_info.value.errors) diff --git a/tests/methodologies/test_protocols.py b/tests/methodologies/test_protocols.py index fd53f7ef..751def56 100644 --- a/tests/methodologies/test_protocols.py +++ b/tests/methodologies/test_protocols.py @@ -279,6 +279,7 @@ class TestExceptionHierarchy: def test_exceptions_are_instantiable_with_message(self): """Test that all exceptions can be instantiated with a message.""" + from pathlib import Path from apps.backend.methodologies.exceptions import ( PluginError, ManifestValidationError, @@ -291,8 +292,13 @@ class TestExceptionHierarchy: e1 = PluginError(msg) assert str(e1) == msg - e2 = ManifestValidationError(msg) - assert str(e2) == msg + # ManifestValidationError now requires path and errors list + test_path = Path("/test/manifest.yaml") + e2 = ManifestValidationError(test_path, [msg]) + assert msg in str(e2) + assert str(test_path) in str(e2) + assert e2.path == test_path + assert e2.errors == [msg] e3 = PluginLoadError(msg) assert str(e3) == msg @@ -533,7 +539,8 @@ class TestNegativeAndEdgeCases: """Test negative scenarios and edge cases.""" def test_exception_with_no_message(self): - """Test exceptions can be instantiated without a message.""" + """Test exceptions can be instantiated without a message (except ManifestValidationError).""" + from pathlib import Path from apps.backend.methodologies.exceptions import ( PluginError, ManifestValidationError, @@ -544,8 +551,10 @@ class TestNegativeAndEdgeCases: e1 = PluginError() assert str(e1) == "" - e2 = ManifestValidationError() - assert str(e2) == "" + # ManifestValidationError requires path and errors - test with empty errors + test_path = Path("/test/manifest.yaml") + e2 = ManifestValidationError(test_path, []) + assert str(test_path) in str(e2) e3 = PluginLoadError() assert str(e3) == "" @@ -562,24 +571,27 @@ class TestNegativeAndEdgeCases: def test_exception_can_be_raised_and_caught(self): """Test exceptions can be raised and caught properly.""" + from pathlib import Path from apps.backend.methodologies.exceptions import ( AutoClaudeError, PluginError, ManifestValidationError, ) + test_path = Path("/test/manifest.yaml") + # Catch specific exception with pytest.raises(ManifestValidationError) as exc_info: - raise ManifestValidationError("Invalid manifest") + raise ManifestValidationError(test_path, ["Invalid manifest"]) assert "Invalid manifest" in str(exc_info.value) # Catch parent exception with pytest.raises(PluginError): - raise ManifestValidationError("Caught as PluginError") + raise ManifestValidationError(test_path, ["Caught as PluginError"]) # Catch grandparent exception with pytest.raises(AutoClaudeError): - raise ManifestValidationError("Caught as AutoClaudeError") + raise ManifestValidationError(test_path, ["Caught as AutoClaudeError"]) def test_phase_result_with_failure(self): """Test PhaseResult can represent failure state."""