Files
hive/core/tests/test_skill_validator.py
Hundao 97ce8dfc54 fix: skip executable permission check on Windows in skill validator (#6894)
Windows has no POSIX executable bits, so the stat-based check always
fails. Skip the check on Windows in the validator, and mark the two
related tests as POSIX-only. Unix CI still catches non-executable
scripts from Windows contributors.

Fixes #6893
2026-04-01 19:37:18 +08:00

407 lines
10 KiB
Python

"""Tests for strict SKILL.md validation (hive skill validate).
One test per strict check — happy path plus each individual failure mode.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
from framework.skills.validator import validate_strict
def _write_skill(tmp_path: Path, content: str, dir_name: str = "my-skill") -> Path:
"""Write a SKILL.md in a named subdirectory and return the path."""
skill_dir = tmp_path / dir_name
skill_dir.mkdir(parents=True, exist_ok=True)
skill_md = skill_dir / "SKILL.md"
skill_md.write_text(content, encoding="utf-8")
return skill_md
_VALID_CONTENT = """\
---
name: my-skill
description: A test skill for validation.
version: 0.1.0
license: MIT
compatibility:
- claude-code
- hive
metadata:
tags: []
---
## Instructions
Do the thing properly.
"""
class TestHappyPath:
def test_valid_skill_passes(self, tmp_path):
path = _write_skill(tmp_path, _VALID_CONTENT)
result = validate_strict(path)
assert result.passed is True
assert result.errors == []
def test_namespace_prefix_name_allowed(self, tmp_path):
"""hive.my-skill with directory my-skill is valid."""
content = """\
---
name: hive.my-skill
description: A namespaced skill.
license: MIT
---
## Body
"""
path = _write_skill(tmp_path, content, dir_name="my-skill")
result = validate_strict(path)
assert result.passed is True
def test_warning_on_missing_license(self, tmp_path):
content = """\
---
name: my-skill
description: No license field.
---
## Body
"""
path = _write_skill(tmp_path, content)
result = validate_strict(path)
assert result.passed is True
assert any("license" in w.lower() for w in result.warnings)
class TestCheck1FileExists:
def test_error_on_missing_file(self, tmp_path):
path = tmp_path / "nonexistent" / "SKILL.md"
result = validate_strict(path)
assert result.passed is False
assert any("not found" in e.lower() for e in result.errors)
class TestCheck2FileNotEmpty:
def test_error_on_empty_file(self, tmp_path):
skill_dir = tmp_path / "my-skill"
skill_dir.mkdir()
path = skill_dir / "SKILL.md"
path.write_text(" \n", encoding="utf-8")
result = validate_strict(path)
assert result.passed is False
assert any("empty" in e.lower() for e in result.errors)
class TestCheck3FrontmatterPresent:
def test_error_on_missing_delimiters(self, tmp_path):
path = _write_skill(tmp_path, "name: my-skill\ndescription: no delimiters\n")
result = validate_strict(path)
assert result.passed is False
assert any("frontmatter" in e.lower() or "---" in e for e in result.errors)
class TestCheck4YamlNoFixup:
def test_error_on_yaml_requiring_fixup(self, tmp_path):
"""Unquoted colon in value — lenient parser accepts, strict rejects."""
content = """\
---
name: my-skill
description: Use for: research tasks
---
## Body
"""
path = _write_skill(tmp_path, content)
result = validate_strict(path)
assert result.passed is False
assert any("YAML" in e or "parse" in e.lower() for e in result.errors)
def test_quoted_colon_passes(self, tmp_path):
content = """\
---
name: my-skill
description: "Use for: research tasks"
license: MIT
---
## Body
"""
path = _write_skill(tmp_path, content)
result = validate_strict(path)
assert result.passed is True
class TestCheck5Description:
def test_error_on_missing_description(self, tmp_path):
content = """\
---
name: my-skill
license: MIT
---
## Body
"""
path = _write_skill(tmp_path, content)
result = validate_strict(path)
assert result.passed is False
assert any("description" in e.lower() for e in result.errors)
def test_error_on_empty_description(self, tmp_path):
content = """\
---
name: my-skill
description: ""
license: MIT
---
## Body
"""
path = _write_skill(tmp_path, content)
result = validate_strict(path)
assert result.passed is False
class TestCheck6NamePresent:
def test_error_on_missing_name(self, tmp_path):
content = """\
---
description: A skill without a name.
license: MIT
---
## Body
"""
path = _write_skill(tmp_path, content)
result = validate_strict(path)
assert result.passed is False
assert any("name" in e.lower() for e in result.errors)
class TestCheck7NameLength:
def test_error_on_name_too_long(self, tmp_path):
long_name = "a" * 65
skill_dir = tmp_path / long_name
skill_dir.mkdir(parents=True)
content = f"---\nname: {long_name}\ndescription: Too long.\nlicense: MIT\n---\n\n## Body\n"
path = skill_dir / "SKILL.md"
path.write_text(content, encoding="utf-8")
result = validate_strict(path)
assert result.passed is False
assert any("64" in e or "characters" in e.lower() for e in result.errors)
def test_exactly_64_chars_passes(self, tmp_path):
name = "a" * 64
skill_dir = tmp_path / name
skill_dir.mkdir(parents=True)
content = f"---\nname: {name}\ndescription: Exactly 64.\nlicense: MIT\n---\n\n## Body\n"
path = skill_dir / "SKILL.md"
path.write_text(content, encoding="utf-8")
result = validate_strict(path)
# May have other warnings but should not error on length
assert not any("64" in e or "characters" in e.lower() for e in result.errors)
class TestCheck8NameDirectoryMatch:
def test_error_on_name_dir_mismatch(self, tmp_path):
content = """\
---
name: other-skill
description: Wrong name.
license: MIT
---
## Body
"""
# Directory is my-skill but name is other-skill
path = _write_skill(tmp_path, content, dir_name="my-skill")
result = validate_strict(path)
assert result.passed is False
assert any("other-skill" in e or "my-skill" in e for e in result.errors)
def test_exact_match_passes(self, tmp_path):
content = """\
---
name: my-skill
description: Exact match.
license: MIT
---
## Body
"""
path = _write_skill(tmp_path, content, dir_name="my-skill")
result = validate_strict(path)
assert result.passed is True
def test_dot_namespace_prefix_passes(self, tmp_path):
"""hive.my-skill with dir my-skill is valid (namespace prefix)."""
content = """\
---
name: org.my-skill
description: Namespaced.
license: MIT
---
## Body
"""
path = _write_skill(tmp_path, content, dir_name="my-skill")
result = validate_strict(path)
# Should not error on name/dir mismatch for namespace prefix
assert not any("my-skill" in e and "other" in e for e in result.errors)
# Check no dir mismatch error specifically
name_mismatch_errors = [e for e in result.errors if "my-skill" in e and "org.my-skill" in e]
assert len(name_mismatch_errors) == 0
class TestCheck9BodyNotEmpty:
def test_error_on_empty_body(self, tmp_path):
content = """\
---
name: my-skill
description: No body.
license: MIT
---
"""
path = _write_skill(tmp_path, content)
result = validate_strict(path)
assert result.passed is False
assert any("body" in e.lower() or "instructions" in e.lower() for e in result.errors)
class TestCheck11Scripts:
@pytest.mark.skipif(sys.platform == "win32", reason="Windows has no POSIX executable bits")
def test_error_on_non_executable_script(self, tmp_path):
path = _write_skill(tmp_path, _VALID_CONTENT)
scripts_dir = path.parent / "scripts"
scripts_dir.mkdir()
script = scripts_dir / "run.sh"
script.write_text("#!/bin/sh\necho hi")
# Ensure NOT executable
script.chmod(0o644)
result = validate_strict(path)
assert result.passed is False
assert any("executable" in e.lower() for e in result.errors)
@pytest.mark.skipif(sys.platform == "win32", reason="Windows has no POSIX executable bits")
def test_passes_with_executable_script(self, tmp_path):
path = _write_skill(tmp_path, _VALID_CONTENT)
scripts_dir = path.parent / "scripts"
scripts_dir.mkdir()
script = scripts_dir / "run.sh"
script.write_text("#!/bin/sh\necho hi")
script.chmod(0o755)
result = validate_strict(path)
assert result.passed is True
class TestCheck12AllowedTools:
def test_warning_on_malformed_allowed_tools(self, tmp_path):
content = """\
---
name: my-skill
description: Skill with bad tools.
license: MIT
allowed-tools: "not a list"
---
## Body
"""
path = _write_skill(tmp_path, content)
result = validate_strict(path)
assert any("allowed-tools" in w.lower() for w in result.warnings)
def test_valid_allowed_tools_no_warning(self, tmp_path):
content = """\
---
name: my-skill
description: Valid tools list.
license: MIT
allowed-tools:
- web_search
- file_read
---
## Body
"""
path = _write_skill(tmp_path, content)
result = validate_strict(path)
assert not any("allowed-tools" in w.lower() for w in result.warnings)
class TestCheck13Compatibility:
def test_error_on_non_list_compatibility(self, tmp_path):
content = """\
---
name: my-skill
description: Bad compat.
license: MIT
compatibility: "claude-code"
---
## Body
"""
path = _write_skill(tmp_path, content)
result = validate_strict(path)
assert result.passed is False
assert any("compatibility" in e.lower() for e in result.errors)
def test_valid_compatibility_passes(self, tmp_path):
content = """\
---
name: my-skill
description: Good compat.
license: MIT
compatibility:
- claude-code
- hive
---
## Body
"""
path = _write_skill(tmp_path, content)
result = validate_strict(path)
assert result.passed is True
class TestCheck14Metadata:
def test_error_on_non_dict_metadata(self, tmp_path):
content = """\
---
name: my-skill
description: Bad metadata.
license: MIT
metadata: "not a dict"
---
## Body
"""
path = _write_skill(tmp_path, content)
result = validate_strict(path)
assert result.passed is False
assert any("metadata" in e.lower() for e in result.errors)
def test_valid_metadata_passes(self, tmp_path):
content = """\
---
name: my-skill
description: Good metadata.
license: MIT
metadata:
tags:
- research
---
## Body
"""
path = _write_skill(tmp_path, content)
result = validate_strict(path)
assert result.passed is True