Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2469,8 +2469,8 @@ def validate_lowercase_f_field(path: Path, text: str) -> Iterable[Violation]:
"apache.org/licenses/LICENSE-2.0",
)

# Files smaller than this threshold (bytes / characters) are treated as
# empty placeholder stubs and exempted from the license-header check.
# Files smaller than this threshold (bytes) are treated as empty
# placeholder stubs and exempted from the license-header check.
_MIN_LICENSE_FILE_SIZE = 50

# Path components that mark generated or vendored subtrees that must not
Expand All @@ -2485,7 +2485,7 @@ def collect_tool_python_files(root: Path | None = None) -> list[Path]:

Excludes generated / vendored subtrees (``.venv``, ``site-packages``,
``node_modules``, ``__pycache__``) and empty placeholder files whose
content is shorter than ``_MIN_LICENSE_FILE_SIZE`` characters.
content is shorter than ``_MIN_LICENSE_FILE_SIZE`` bytes.
"""
base = (root or find_repo_root()) / TOOLS_DIR
if not base.exists():
Expand Down Expand Up @@ -3297,8 +3297,18 @@ def validate_eval_coverage(root: Path | None = None) -> Iterable[Violation]:
return
eval_slugs: set[str] = set()
if evals_base.exists():
eval_slugs = {p.name for p in evals_base.iterdir() if p.is_dir()}
for skill_dir in sorted(skills_base.iterdir()):
try:
eval_slugs = {p.name for p in evals_base.iterdir() if p.is_dir()}
except OSError:
# Restrictive runners can deny listing; skip rather than crash
# (same posture as collect_tool_python_files on OSError).
return
try:
skill_dirs = sorted(skills_base.iterdir())
except OSError:
# Same posture as collect_tool_python_files: unreadable → skip.
return
for skill_dir in skill_dirs:
if not skill_dir.is_dir():
continue
# A trusted-external-skill-source pointer dir carries its eval suite
Expand Down
66 changes: 65 additions & 1 deletion tools/skill-and-tool-validator/tests/test_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from __future__ import annotations

import subprocess
from collections.abc import Iterator
from pathlib import Path

import pytest
Expand Down Expand Up @@ -1595,6 +1596,21 @@ def test_lowercase_f_field_in_soft_categories(self) -> None:
_SPDX_PY_HEADER = "# SPDX-License-Identifier: Apache-2.0\n"


def _live_repo_root_for_header_check() -> Path | None:
"""Return the magpie repo root when the live license-header check can run.

``find_repo_root`` never raises — out-of-tree it returns CWD — so the
live integration must additionally confirm this checkout actually
contains the validator package under ``tools/``. Returns ``None`` when
that marker is absent so callers can ``skipif`` instead of aborting.
"""
root = find_repo_root()
marker = root / "tools" / "skill-and-tool-validator" / "src" / "skill_and_tool_validator"
if marker.is_dir():
return root
return None


class TestValidateLicenseHeader:
# ------------------------------------------------------------------ #
# Python (.py) checks #
Expand Down Expand Up @@ -1683,11 +1699,23 @@ def test_collect_tool_python_files_returns_empty_when_no_tools_dir(self, tmp_pat
# Integration: real repo passes #
# ------------------------------------------------------------------ #

def test_live_repo_header_check_skips_out_of_tree(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Out-of-tree cwd must make the live header-check skipif fire (not error)."""
monkeypatch.chdir(tmp_path)
assert _live_repo_root_for_header_check() is None

@pytest.mark.skipif(
_live_repo_root_for_header_check() is None,
reason="not running inside a magpie repo checkout with tools/",
)
def test_real_repo_tool_python_files_all_have_headers(self) -> None:
"""Every non-trivial tool Python file in the real repo carries a license header."""
from skill_and_tool_validator import _LICENSE_PY_MARKERS

repo_root = find_repo_root()
repo_root = _live_repo_root_for_header_check()
assert repo_root is not None # skipif above guarantees this
missing = [
p
for p in collect_tool_python_files(repo_root)
Expand Down Expand Up @@ -3635,6 +3663,42 @@ def test_non_directory_entries_in_skills_are_skipped(self, tmp_path: Path) -> No
violations = list(validate_eval_coverage(tmp_path))
assert violations == []

def test_unreadable_skills_dir_returns_no_violations(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""PermissionError on skills/.iterdir() must not abort the run."""
skills_dir = tmp_path / "skills"
skills_dir.mkdir(parents=True)
self._make_skill(tmp_path, "alpha")

real_iterdir = Path.iterdir

def _boom(self: Path) -> Iterator[Path]:
if self.resolve() == skills_dir.resolve():
raise PermissionError("denied")
return real_iterdir(self)

monkeypatch.setattr(Path, "iterdir", _boom)
assert list(validate_eval_coverage(tmp_path)) == []

def test_unreadable_evals_dir_returns_no_violations(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""PermissionError on evals/.iterdir() must not abort or false-flag skills."""
self._make_skill(tmp_path, "alpha")
evals_dir = tmp_path / "tools" / "skill-evals" / "evals"
evals_dir.mkdir(parents=True)

real_iterdir = Path.iterdir

def _boom(self: Path) -> Iterator[Path]:
if self.resolve() == evals_dir.resolve():
raise PermissionError("denied")
return real_iterdir(self)

monkeypatch.setattr(Path, "iterdir", _boom)
assert list(validate_eval_coverage(tmp_path)) == []


# ---------------------------------------------------------------------------
# docs/modes.md consistency check (check #11 — SOFT)
Expand Down