Files
hive/core/framework/skills/validator.py
T
Hundao 589c5b06fe fix: resolve all ruff lint and format errors across codebase (#7058)
- Auto-fixed 70 lint errors (import sorting, aliased errors, datetime.UTC)
- Fixed 85 remaining errors manually:
  - E501: wrapped long lines in queen_profiles, catalog, routes_credentials
  - F821: added missing TYPE_CHECKING imports for AgentHost, ToolRegistry,
    HookContext, HookResult; added runtime imports where needed
  - F811: removed duplicate method definitions in queen_lifecycle_tools
  - F841/B007: removed unused variables in discovery.py
  - W291: removed trailing whitespace in queen nodes
  - E402: moved import to top of queen_memory_v2.py
  - Fixed AgentRuntime -> AgentHost in example template type annotations
- Reformatted 343 files with ruff format
2026-04-16 19:30:01 +08:00

169 lines
6.3 KiB
Python

"""Strict SKILL.md validation for contributor tooling (hive skill validate).
Unlike the lenient parser used at runtime, this module applies hard-error rules
that match the Agent Skills specification exactly. Intended for contributor
tooling, CI gates, and hive skill doctor.
"""
from __future__ import annotations
import stat
import sys
from dataclasses import dataclass, field
from pathlib import Path
from framework.skills.parser import _MAX_NAME_LENGTH
@dataclass
class ValidationResult:
"""Result of a strict SKILL.md validation run."""
passed: bool
errors: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
def validate_strict(path: Path) -> ValidationResult:
"""Run all strict checks against a SKILL.md file.
Applies hard-error rules that go beyond the lenient runtime parser:
- name must be explicit (no directory-name fallback)
- YAML must parse without fixup
- name/directory mismatch is an error, not a warning
- empty body is an error
- scripts must be executable
Args:
path: Path to the SKILL.md file to validate.
Returns:
ValidationResult with passed=True if no errors, plus any warnings.
"""
errors: list[str] = []
warnings: list[str] = []
# 1. File exists and is readable
try:
content = path.read_text(encoding="utf-8")
except FileNotFoundError:
return ValidationResult(passed=False, errors=[f"File not found: {path}"])
except PermissionError:
return ValidationResult(passed=False, errors=[f"Permission denied reading: {path}"])
except OSError as exc:
return ValidationResult(passed=False, errors=[f"Cannot read file: {exc}"])
# 2. File not empty
if not content.strip():
return ValidationResult(passed=False, errors=["File is empty."])
# 3. YAML frontmatter present
parts = content.split("---", 2)
if len(parts) < 3:
return ValidationResult(
passed=False,
errors=["Missing YAML frontmatter — wrap frontmatter with --- delimiters."],
)
raw_yaml = parts[1].strip()
body = parts[2].strip()
if not raw_yaml:
return ValidationResult(
passed=False,
errors=["Frontmatter delimiters present but YAML block is empty."],
)
# 4. YAML parses WITHOUT fixup (strict: unquoted colons are an error)
import yaml
frontmatter: dict | None = None
try:
frontmatter = yaml.safe_load(raw_yaml)
except yaml.YAMLError as exc:
errors.append(
f'YAML parse error: {exc}. Wrap values containing colons in quotes, e.g. description: "Use for: research".'
)
return ValidationResult(passed=False, errors=errors, warnings=warnings)
if not isinstance(frontmatter, dict):
return ValidationResult(
passed=False,
errors=["Frontmatter is not a YAML key-value mapping."],
)
# 5. description present and non-empty
description = frontmatter.get("description")
if not description or not str(description).strip():
errors.append("Missing required field: 'description' must be present and non-empty.")
# 6. name present and non-empty (no directory-name fallback in strict mode)
name = frontmatter.get("name")
if not name or not str(name).strip():
errors.append("Missing required field: 'name' must be present. Add 'name: your-skill-name' to the frontmatter.")
else:
name = str(name).strip()
parent_dir_name = path.parent.name
# 7. name length <= 64 chars
if len(name) > _MAX_NAME_LENGTH:
errors.append(
f"Skill name '{name}' is {len(name)} characters — maximum is {_MAX_NAME_LENGTH}. Shorten the name."
)
# 8. name matches parent directory (dot-namespace prefix allowed: hive.X with dir X)
if name != parent_dir_name and not name.endswith(f".{parent_dir_name}"):
errors.append(
f"Name '{name}' does not match directory '{parent_dir_name}'. "
f"Rename the directory to '{name}' or set name to '{parent_dir_name}'."
)
# 9. body non-empty
if not body:
errors.append("Skill body (instructions) is empty. Add markdown instructions after the closing --- delimiter.")
# 10. license present — warning only
if not frontmatter.get("license"):
warnings.append("No 'license' field — consider adding a license (e.g. MIT, Apache-2.0).")
# 11. Scripts in scripts/ exist and are executable
# Windows has no POSIX executable bits; skip this check there.
base_dir = path.parent
scripts_dir = base_dir / "scripts"
if scripts_dir.is_dir() and sys.platform != "win32":
for script_path in sorted(scripts_dir.iterdir()):
if script_path.is_file():
if not (script_path.stat().st_mode & (stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)):
errors.append(f"Script not executable: {script_path.name}. Run: chmod +x {script_path}")
# 12. allowed-tools entries are non-empty strings — warning if malformed
allowed_tools = frontmatter.get("allowed-tools")
if allowed_tools is not None:
if not isinstance(allowed_tools, list):
warnings.append("'allowed-tools' should be a list of strings.")
else:
for tool in allowed_tools:
if not isinstance(tool, str) or not tool.strip():
warnings.append(f"'allowed-tools' entry {tool!r} is not a non-empty string.")
# 13. compatibility is a list of strings — error if malformed
compatibility = frontmatter.get("compatibility")
if compatibility is not None:
if not isinstance(compatibility, list):
errors.append("'compatibility' must be a list of strings.")
else:
for item in compatibility:
if not isinstance(item, str):
errors.append(f"'compatibility' entry {item!r} is not a string.")
# 14. metadata is a dict — error if malformed
metadata = frontmatter.get("metadata")
if metadata is not None and not isinstance(metadata, dict):
errors.append("'metadata' must be a YAML mapping (dict), not a scalar or list.")
return ValidationResult(
passed=len(errors) == 0,
errors=errors,
warnings=warnings,
)