Files
hermes-agent/tests/skills/test_optional_skill_self_paths.py
liuhao1024 5141b3122b fix(skills): point the godmode skill's self-referencing install paths at its new location
The skill was moved to the security category as a pure R100 rename
(fdc90346ea), but the eleven install-path strings embedded in its own docs
and scripts kept the pre-move category. On a fresh install the skill lands
under the security category in the skills home, so every copy-pasteable
snippet in the docs and the loader fallbacks in the scripts raise
FileNotFoundError on first use. Rewrite both embedding forms — the
joined-string form and the segmented Path(...) form — and add a CI
contract test that fails when any optional skill references its own
install path under a category that does not match its location.
2026-09-20 11:13:24 -07:00

78 lines
3.1 KiB
Python

"""Contract: a skill's self-referencing install paths match its own location.
The hub installs ``optional-skills/<category>/<name>`` to
``$HERMES_HOME/skills/<category>/<name>``, preserving the category path.
When a skill is moved between categories, the install-path strings embedded
in its own docs and scripts (joined ``skills/<category>/<name>/...`` and the
segmented ``Path(...) / "skills" / "<category>" / "<name>"`` form) keep
pointing at the pre-move directory, so every copy-pasteable snippet raises
FileNotFoundError (#115695). Nothing else catches that drift today; this
test does, for every optional skill, at CI time.
Only *self*-references are checked (the reference names the skill itself),
so cross-skill references and foreign-repo paths in port notes are immune.
"""
import re
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
OPTIONAL = REPO / "optional-skills"
# Text formats that carry copy-pasteable install paths; data files can be
# huge, so cap the scan well above any real doc/script.
_SCAN_SUFFIXES = {".md", ".py", ".txt", ".sh", ".yaml", ".yml", ".toml", ".json"}
_MAX_SCAN_BYTES = 2 * 1024 * 1024
def _skill_roots():
roots = {skill_md.parent for skill_md in OPTIONAL.rglob("SKILL.md")}
return sorted(roots)
def _scan_files(root):
for path in sorted(root.rglob("*")):
if not path.is_file() or path.suffix.lower() not in _SCAN_SUFFIXES:
continue
if path.stat().st_size > _MAX_SCAN_BYTES:
continue
yield path
def _stale_self_references(root):
"""Yield (file, line_no, snippet) for self-install paths that cannot resolve."""
name = re.escape(root.name)
# skills/<category>/<name>/... — joined-string form
joined = re.compile(rf"skills/([\w.-]+)/{name}\b")
# Path(...) / "skills" / "<category>" / "<name>" — segmented form
segmented = re.compile(
rf'["\']skills["\']\s*/\s*["\']([\w.-]+)["\']\s*/\s*["\']{name}["\']'
)
stale = []
for path in _scan_files(root):
try:
text = path.read_text(encoding="utf-8")
except (UnicodeDecodeError, OSError):
continue
for line_no, line in enumerate(text.splitlines(), start=1):
for match in joined.finditer(line):
category = match.group(1)
if not (OPTIONAL / category / root.name).is_dir():
stale.append((path.relative_to(REPO), line_no, match.group(0)))
for match in segmented.finditer(line):
category = match.group(1)
if not (OPTIONAL / category / root.name).is_dir():
stale.append((path.relative_to(REPO), line_no, match.group(0)))
return stale
def test_optional_skills_self_install_paths_resolve_to_their_own_location():
offenders = []
for root in _skill_roots():
offenders.extend(_stale_self_references(root))
assert not offenders, (
"Optional skills reference their own install path under a category "
"that does not match their location (the skill moved, the embedded "
"paths did not):\n"
+ "\n".join(f" {f}:{line}: {snippet}" for f, line, snippet in offenders)
)