diff --git a/tests/tools/test_skill_linter.py b/tests/tools/test_skill_linter.py new file mode 100644 index 0000000000000..b4c03692b2018 --- /dev/null +++ b/tests/tools/test_skill_linter.py @@ -0,0 +1,189 @@ +"""Tests for tools/skill_linter.py — the advisory SKILL.md convention linter.""" + +from pathlib import Path + +import pytest + +from tools.skill_linter import ( + ERROR, + WARNING, + format_findings, + has_errors, + lint_content, + lint_skill, +) + +# A clean, peer-shaped SKILL.md that should produce zero findings. +CLEAN = """--- +name: my-skill +description: Search arXiv papers by keyword, author, or ID. +version: 1.0.0 +author: Hermes Agent +license: MIT +metadata: + hermes: + tags: [arxiv, research] + related_skills: [] +--- + +# My Skill + +## Overview +Does a thing. + +## When to Use +- When the user wants X. + +## Procedure +1. Use `read_file` to load it. +""" + + +def _rules(findings): + return {f.rule for f in findings} + + +def test_clean_skill_has_no_findings(): + assert lint_content(CLEAN) == [] + + +def test_description_too_long_is_warning(): + long_desc = "x" * 80 + content = CLEAN.replace( + "Search arXiv papers by keyword, author, or ID.", long_desc + ) + findings = lint_content(content) + assert "description-length" in _rules(findings) + assert all(f.severity == WARNING for f in findings) + + +def test_marketing_words_flagged(): + content = CLEAN.replace( + "Search arXiv papers by keyword, author, or ID.", + "A powerful comprehensive tool.", + ) + findings = lint_content(content) + assert "description-marketing" in _rules(findings) + + +def test_shell_utility_reference_in_prose_flagged(): + content = CLEAN.replace("Use `read_file` to load it.", "Use `grep` to find it.") + findings = lint_content(content) + assert "shell-utility-reference" in _rules(findings) + + +def test_shell_utility_inside_code_block_not_flagged(): + # A fenced code block legitimately shows grep; prose check must skip it. + content = CLEAN + "\n```bash\ngrep -r foo .\n```\n" + findings = lint_content(content) + assert "shell-utility-reference" not in _rules(findings) + + +def test_missing_metadata_block_warns(): + content = """--- +name: bare-skill +description: Does a thing briefly. +--- + +# Bare Skill + +## When to Use +- now +""" + findings = lint_content(content) + rules = _rules(findings) + assert "missing-metadata" in rules + + +def test_missing_when_to_use_section_warns(): + content = CLEAN.replace("## When to Use\n- When the user wants X.\n", "") + findings = lint_content(content) + assert "missing-section" in _rules(findings) + + +def test_bad_name_format_is_error(): + content = CLEAN.replace("name: my-skill", "name: My_Skill!") + findings = lint_content(content) + assert "name-format" in _rules(findings) + assert has_errors(findings) + + +def test_name_dir_mismatch_is_error(tmp_path): + skill_dir = tmp_path / "actual-dir" + skill_dir.mkdir() + findings = lint_content(CLEAN, skill_dir=skill_dir) # name is my-skill + assert "name-dir-mismatch" in _rules(findings) + assert has_errors(findings) + + +def test_dangling_reference_link_flagged(tmp_path): + skill_dir = tmp_path / "my-skill" + skill_dir.mkdir() + content = CLEAN + "\nSee references/missing.md for detail.\n" + findings = lint_content(content, skill_dir=skill_dir) + assert "dangling-reference" in _rules(findings) + + +def test_present_reference_link_not_flagged(tmp_path): + skill_dir = tmp_path / "my-skill" + (skill_dir / "references").mkdir(parents=True) + (skill_dir / "references" / "detail.md").write_text("x") + content = CLEAN + "\nSee references/detail.md for detail.\n" + findings = lint_content(content, skill_dir=skill_dir) + assert "dangling-reference" not in _rules(findings) + + +def test_posix_primitive_without_platforms_warns(tmp_path): + skill_dir = tmp_path / "my-skill" + (skill_dir / "scripts").mkdir(parents=True) + (skill_dir / "scripts" / "run.py").write_text("import fcntl\nfcntl.flock(1, 2)\n") + findings = lint_content(CLEAN, skill_dir=skill_dir) + assert "platforms-gating" in _rules(findings) + + +def test_posix_primitive_with_platforms_ok(tmp_path): + skill_dir = tmp_path / "my-skill" + (skill_dir / "scripts").mkdir(parents=True) + (skill_dir / "scripts" / "run.py").write_text("import fcntl\n") + content = CLEAN.replace( + "version: 1.0.0", "version: 1.0.0\nplatforms: [linux, macos]" + ) + findings = lint_content(content, skill_dir=skill_dir) + assert "platforms-gating" not in _rules(findings) + + +def test_forbidden_file_flagged(tmp_path): + skill_dir = tmp_path / "my-skill" + skill_dir.mkdir() + (skill_dir / "README.md").write_text("# readme") + findings = lint_content(CLEAN, skill_dir=skill_dir) + assert "forbidden-file" in _rules(findings) + + +def test_invalid_platforms_value_warns(): + content = CLEAN.replace( + "version: 1.0.0", "version: 1.0.0\nplatforms: [linux, solaris]" + ) + findings = lint_content(content) + assert "platforms-value" in _rules(findings) + + +def test_lint_skill_reads_from_disk(tmp_path): + skill_dir = tmp_path / "my-skill" + skill_dir.mkdir() + skill_md = skill_dir / "SKILL.md" + skill_md.write_text(CLEAN) + findings = lint_skill(skill_md) + assert findings == [] + + +def test_author_caps_warned(): + content = CLEAN.replace("author: Hermes Agent", "author: hermes agent") + findings = lint_content(content) + assert "author-caps" in _rules(findings) + + +def test_format_findings_renders(): + findings = lint_content(CLEAN.replace("name: my-skill", "name: BAD")) + out = format_findings(findings) + assert "name-format" in out diff --git a/tools/skill_linter.py b/tools/skill_linter.py new file mode 100644 index 0000000000000..f838547bf8540 --- /dev/null +++ b/tools/skill_linter.py @@ -0,0 +1,462 @@ +"""Structural + convention linter for SKILL.md files. + +The hard *validator* in ``tools/skill_manager_tool.py::_validate_frontmatter`` +guards the non-negotiables (fence present, YAML mapping, ``name`` + +``description`` present, description length, non-empty body, size cap) and is a +create/edit BLOCKER. This module is the softer, broader companion: it encodes +the "Skill authoring standards (HARDLINE)" conventions from ``CONTRIBUTING.md`` +that today are only caught by a human reviewer — shell-utility references +instead of native tools, a missing author/license/metadata block, a +``name`` that doesn't match its directory, dangling ``references/`` links, +marketing words in the description, ``platforms:`` gating vs POSIX-only +primitives, and forbidden scaffolding files. + +Design contract (matches the Hermes "no lazy-reading escape hatches / don't +destroy the feature" posture): + +* Findings are **advisory** by default. ``lint_skill`` returns a list of + :class:`LintFinding`; the caller decides whether any severity blocks. The + create-path surfaces them as guidance, never as a hard reject (the hard + rejects already live in the validator). +* Pure functions, no I/O beyond reading the files it is pointed at, so CI, the + ``skill_manage`` create path, and a contributor running it locally all share + one implementation. +* Reuses ``agent.skill_utils`` helpers rather than re-parsing frontmatter, so + BOM handling / platform matching / the 60-char prompt budget stay in one + place. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, List, Optional + +from agent.skill_utils import ( + SKILL_PROMPT_DESC_LIMIT, + parse_frontmatter, +) + +# ── Rule data ──────────────────────────────────────────────────────────────── + +# Shell utilities the agent already has wrapped as first-class tools. Naming +# them in SKILL.md prose steers the model to a raw shell call instead of the +# native tool. Maps the banned token -> the native tool the prose should name. +# (CONTRIBUTING.md "Skill authoring standards" rule 2.) +_SHELL_UTIL_TO_TOOL: Dict[str, str] = { + "grep": "search_files", + "rg": "search_files", + "cat": "read_file", + "head": "read_file", + "tail": "read_file", + "sed": "patch", + "awk": "patch", + "find": "search_files (target='files')", + "ls": "search_files (target='files')", +} + +# Marketing words the description must not contain (rule 1). +_MARKETING_WORDS = ( + "powerful", + "comprehensive", + "seamless", + "advanced", + "cutting-edge", + "state-of-the-art", + "revolutionary", + "robust", +) + +# POSIX-only primitives that, if a bundled script uses them, require the skill +# to declare ``platforms:`` (rule 3). Detected in scripts/, not in prose. +_POSIX_PRIMITIVES = ( + "fcntl", + "termios", + "os.setsid", + "signal.SIGKILL", + "osascript", + "/proc/", + "apt-get", + "systemctl", +) + +# Scaffolding files a skill should not ship (skill-creator anti-pattern; keeps +# skills dense). These are noise, not skill content. +_FORBIDDEN_FILES = ( + "README.md", + "CHANGELOG.md", + "install.sh", + ".env", + ".env.example", + ".gitignore", +) + +# Recommended modern section order (rule 5). We check presence of the load +# bearing ones, not exact ordering, to avoid being a change-detector. +_EXPECTED_SECTIONS = ("When to Use", "When to use") + +ERROR = "error" +WARNING = "warning" + + +@dataclass +class LintFinding: + """A single lint result. ``severity`` is advisory metadata for the caller.""" + + severity: str # ERROR | WARNING + rule: str + message: str + + def format(self) -> str: + badge = "✗" if self.severity == ERROR else "⚠" + return f"{badge} [{self.rule}] {self.message}" + + +# ── Individual checks ──────────────────────────────────────────────────────── + + +def _check_name_matches_dir( + frontmatter: Dict[str, Any], skill_dir: Optional[Path] +) -> List[LintFinding]: + if skill_dir is None: + return [] + name = str(frontmatter.get("name", "")).strip() + if not name: + return [] + if name != skill_dir.name: + return [ + LintFinding( + ERROR, + "name-dir-mismatch", + f"frontmatter name '{name}' does not match directory " + f"'{skill_dir.name}'; they must be identical.", + ) + ] + return [] + + +def _check_name_format(frontmatter: Dict[str, Any]) -> List[LintFinding]: + name = str(frontmatter.get("name", "")).strip() + if not name: + return [] + if not re.fullmatch(r"[a-z0-9][a-z0-9_-]*", name): + return [ + LintFinding( + ERROR, + "name-format", + f"name '{name}' must be lowercase letters, digits, hyphens, " + f"and underscores only.", + ) + ] + return [] + + +def _check_description(frontmatter: Dict[str, Any]) -> List[LintFinding]: + findings: List[LintFinding] = [] + # Raw description as authored — extract_skill_description() applies the + # 60-char prompt truncation, so it can never exceed the limit; measure the + # raw frontmatter value for the length check. + desc = str(frontmatter.get("description", "")).strip().strip("'\"") + if not desc: + return findings + if len(desc) > SKILL_PROMPT_DESC_LIMIT: + findings.append( + LintFinding( + WARNING, + "description-length", + f"description is {len(desc)} chars; the skill index truncates " + f"past {SKILL_PROMPT_DESC_LIMIT} chars + '...', losing routing " + f"signal. Keep it to one sentence.", + ) + ) + lower = desc.lower() + hits = [w for w in _MARKETING_WORDS if re.search(rf"\b{re.escape(w)}\b", lower)] + if hits: + findings.append( + LintFinding( + WARNING, + "description-marketing", + f"description contains marketing words {hits}; state the " + f"capability, not adjectives.", + ) + ) + return findings + + +def _check_metadata_block(frontmatter: Dict[str, Any]) -> List[LintFinding]: + findings: List[LintFinding] = [] + for key in ("version", "author", "license"): + if key not in frontmatter: + findings.append( + LintFinding( + WARNING, + "missing-metadata", + f"frontmatter is missing '{key}'; every peer skill has it.", + ) + ) + meta = frontmatter.get("metadata") + hermes_meta = meta.get("hermes") if isinstance(meta, dict) else None + if not isinstance(hermes_meta, dict): + findings.append( + LintFinding( + WARNING, + "missing-metadata", + "frontmatter is missing metadata.hermes.{tags, related_skills}.", + ) + ) + else: + if "tags" not in hermes_meta: + findings.append( + LintFinding( + WARNING, "missing-metadata", "metadata.hermes.tags is missing." + ) + ) + author = str(frontmatter.get("author", "")) + if author and author.strip().lower() in ("hermes", "agent", "hermes agent") and ( + author != "Hermes Agent" + ): + findings.append( + LintFinding( + WARNING, + "author-caps", + f"author '{author}' should be 'Hermes Agent' (proper caps) " + f"or a real contributor name.", + ) + ) + return findings + + +def _check_shell_utilities(body: str) -> List[LintFinding]: + """Flag banned shell utilities named in PROSE (not fenced code blocks).""" + findings: List[LintFinding] = [] + prose = _strip_code_blocks(body) + for util, tool in _SHELL_UTIL_TO_TOOL.items(): + # Backtick-wrapped mention in prose, e.g. `grep` — the failure mode + # CONTRIBUTING rule 2 targets. Bare words in sentences are too noisy. + if re.search(rf"`{re.escape(util)}`", prose): + findings.append( + LintFinding( + WARNING, + "shell-utility-reference", + f"prose references `{util}`; name the native tool " + f"`{tool}` instead.", + ) + ) + return findings + + +def _check_sections(body: str) -> List[LintFinding]: + if not any(re.search(rf"^#+\s+{re.escape(s)}", body, re.M) for s in _EXPECTED_SECTIONS): + return [ + LintFinding( + WARNING, + "missing-section", + "no '## When to Use' section found; skills need explicit " + "trigger conditions near the top.", + ) + ] + return [] + + +def _check_reference_links(body: str, skill_dir: Optional[Path]) -> List[LintFinding]: + """Flag references/ links in the body that don't resolve on disk.""" + if skill_dir is None: + return [] + findings: List[LintFinding] = [] + seen: set[str] = set() + # Only references/, templates/, assets/ are reliably skill-owned. `scripts/` + # is excluded: dev skills routinely mention repo-root scripts like + # `scripts/run_tests.sh` that legitimately live outside the skill dir. + for match in re.finditer(r"(references|templates|assets)/[\w./-]+", body): + rel = match.group(0) + if rel in seen: + continue + seen.add(rel) + # Skip obvious placeholders / globs. + if "*" in rel or rel.endswith("/"): + continue + if not (skill_dir / rel).exists(): + findings.append( + LintFinding( + WARNING, + "dangling-reference", + f"body references '{rel}' but that file does not exist " + f"in the skill directory.", + ) + ) + return findings + + +def _check_platforms_gating( + frontmatter: Dict[str, Any], skill_dir: Optional[Path] +) -> List[LintFinding]: + """If bundled scripts use POSIX-only primitives, require platforms:.""" + if skill_dir is None: + return [] + if frontmatter.get("platforms"): + return [] # already gated + scripts_dir = skill_dir / "scripts" + if not scripts_dir.is_dir(): + return [] + offenders: Dict[str, List[str]] = {} + for script in scripts_dir.rglob("*"): + if not script.is_file() or script.suffix not in (".py", ".sh", ".bash"): + continue + try: + text = script.read_text(encoding="utf-8", errors="ignore") + except OSError: + continue + hit = [p for p in _POSIX_PRIMITIVES if p in text] + if hit: + offenders[script.name] = hit + if offenders: + detail = "; ".join(f"{k}: {v}" for k, v in offenders.items()) + return [ + LintFinding( + WARNING, + "platforms-gating", + f"scripts use POSIX-only primitives ({detail}) but no " + f"'platforms:' frontmatter is declared. Fix cross-platform or " + f"gate with platforms: [linux, macos].", + ) + ] + return [] + + +def _check_forbidden_files(skill_dir: Optional[Path]) -> List[LintFinding]: + if skill_dir is None: + return [] + findings: List[LintFinding] = [] + for fname in _FORBIDDEN_FILES: + if (skill_dir / fname).exists(): + findings.append( + LintFinding( + WARNING, + "forbidden-file", + f"skill ships '{fname}'; skills should not include " + f"scaffolding/config files.", + ) + ) + return findings + + +def _check_platform_list_valid(frontmatter: Dict[str, Any]) -> List[LintFinding]: + platforms = frontmatter.get("platforms") + if not platforms: + return [] + valid = {"linux", "macos", "windows", "darwin"} + items = platforms if isinstance(platforms, list) else [platforms] + bad = [p for p in items if str(p).lower() not in valid] + if bad: + return [ + LintFinding( + WARNING, + "platforms-value", + f"platforms contains unrecognized value(s) {bad}; expected a " + f"subset of {sorted(valid)}.", + ) + ] + return [] + + +# ── Helpers ────────────────────────────────────────────────────────────────── + + +def _strip_code_blocks(body: str) -> str: + """Remove fenced code blocks so prose-only checks don't fire on examples.""" + return re.sub(r"```.*?```", "", body, flags=re.S) + + +# ── Public API ─────────────────────────────────────────────────────────────── + + +def lint_content( + content: str, *, skill_dir: Optional[Path] = None +) -> List[LintFinding]: + """Lint raw SKILL.md *content*. + + Pass ``skill_dir`` to enable on-disk checks (name/dir match, dangling + reference links, POSIX-primitive gating, forbidden files). Without it, + only content-only checks run — useful for the create path, where the file + is not yet written. + """ + frontmatter, body = parse_frontmatter(content) + findings: List[LintFinding] = [] + findings += _check_name_format(frontmatter) + findings += _check_name_matches_dir(frontmatter, skill_dir) + findings += _check_description(frontmatter) + findings += _check_metadata_block(frontmatter) + findings += _check_platform_list_valid(frontmatter) + findings += _check_shell_utilities(body) + findings += _check_sections(body) + findings += _check_reference_links(body, skill_dir) + findings += _check_platforms_gating(frontmatter, skill_dir) + findings += _check_forbidden_files(skill_dir) + return findings + + +def lint_skill(skill_md_path: Path) -> List[LintFinding]: + """Lint a SKILL.md file on disk, with all on-disk checks enabled.""" + skill_md_path = Path(skill_md_path) + content = skill_md_path.read_text(encoding="utf-8", errors="ignore") + return lint_content(content, skill_dir=skill_md_path.parent) + + +def format_findings(findings: List[LintFinding]) -> str: + """Render findings as a newline-joined human-readable block.""" + return "\n".join(f.format() for f in findings) + + +def has_errors(findings: List[LintFinding]) -> bool: + return any(f.severity == ERROR for f in findings) + + +def _main(argv: Optional[List[str]] = None) -> int: + """CLI: ``python -m tools.skill_linter ...`` + + Accepts SKILL.md files or skill directories (recursively linted). Prints + findings grouped per skill. Exit code 1 if any ERROR-severity finding is + present (WARNING-only is exit 0), so CI can gate on structural breakage + without failing on advisory convention nits. + """ + import sys + + args = argv if argv is not None else sys.argv[1:] + if not args: + print("usage: python -m tools.skill_linter ...") + return 2 + + targets: List[Path] = [] + for arg in args: + p = Path(arg) + if p.is_dir(): + targets.extend(sorted(p.rglob("SKILL.md"))) + elif p.name == "SKILL.md" and p.is_file(): + targets.append(p) + else: + print(f"skip (not a SKILL.md or dir): {arg}") + + any_error = False + total = 0 + for skill_md in targets: + findings = lint_skill(skill_md) + if not findings: + continue + total += len(findings) + if has_errors(findings): + any_error = True + print(f"\n{skill_md.parent.name} ({skill_md}):") + print(format_findings(findings)) + + if total == 0: + print(f"All {len(targets)} skill(s) clean.") + else: + print(f"\n{total} finding(s) across {len(targets)} skill(s).") + return 1 if any_error else 0 + + +if __name__ == "__main__": + import sys + + sys.exit(_main()) diff --git a/tools/skill_manager_tool.py b/tools/skill_manager_tool.py index 9ef337144d223..0d80722025e99 100644 --- a/tools/skill_manager_tool.py +++ b/tools/skill_manager_tool.py @@ -971,9 +971,38 @@ def _create_skill(name: str, content: str, category: str = None) -> Dict[str, An "skill_manage(action='write_file', name='{}', file_path='references/example.md', file_content='...')".format(name) ) _add_description_prompt_preview(result, content) + _attach_lint_findings(result, skill_md) return result +def _attach_lint_findings(result: Dict[str, Any], skill_md: Path) -> None: + """Run the advisory SKILL.md linter and attach any findings to *result*. + + The linter enforces the CONTRIBUTING "Skill authoring standards (HARDLINE)" + conventions that the hard validator does not (shell-utility references, + missing metadata, dangling reference links, POSIX gating, forbidden files). + Findings are ADVISORY — surfaced as guidance so the author can fix them, + never a hard block. The hard rejects already ran in _validate_frontmatter. + """ + try: + from tools.skill_linter import lint_skill # local import: optional path + + findings = lint_skill(skill_md) + except Exception: + return + if not findings: + return + result["lint_warnings"] = [ + {"severity": f.severity, "rule": f.rule, "message": f.message} + for f in findings + ] + result["lint_hint"] = ( + "The skill was created. These are advisory authoring-convention " + "findings (not blockers) — fix them with skill_manage(action='patch') " + "to match Hermes skill standards." + ) + + def _edit_skill(name: str, content: str) -> Dict[str, Any]: """Replace the SKILL.md of any existing skill (full rewrite).""" err = _validate_frontmatter(content)