tests/test_install_references.py
Rama v8 · réplica de lectura del repositorio original.
"""Tests for the progressive-disclosure references/ sidecar install path.
The real claude bundle now ships in the package (graphify/skills/claude/), so
claude and its reuse twins (antigravity, kimi) install progressively: a lean
SKILL.md plus a references/ sidecar. Every other host whose bundle has not
shipped yet still installs today's byte-identical monolith.
The plumbing tests below stage a hand-made fake bundle in a throwaway package
root (not in the real package), so the working tree is never disturbed.
"""
from __future__ import annotations
import os
import shutil
import sys
from pathlib import Path
from unittest.mock import patch
import pytest
import graphify
import graphify.__main__ as mainmod
PKG_DIR = Path(graphify.__file__).parent
class _FakeBundle(str):
"""The platform name, carrying the staged fake package root.
A str subclass so existing ``platform = fake_bundle`` call sites keep
working while tests that need the staged bundle path (the read-only-package
test) can read ``fake_bundle.pkg_root``.
"""
pkg_root: Path
def _stage_fake_package(root: Path, *, references: "tuple[str, str] | None" = ("# extraction spec fragment\n", "# query fragment\n")) -> Path:
"""Materialise a throwaway mini graphify package for the install tests.
The installer resolves every packaged asset relative to
``graphify.install.__file__`` (skill bodies, ``skills/<bundle>/references/``,
``always_on/*.md``, ``command-kilo.md``). Pointing that ``__file__`` at a
fake package root under ``tmp_path`` lets the tests stage their fake bundle
there instead of moving the real committed bundle out of the on-disk
package: a killed run (SIGTERM/SIGKILL runs no ``finally`` blocks) used to
leave the fake bundle in the package, dirtying the checkout, and a second
pytest on the same checkout read a package whose bundle was moved away
(#4603). With the fake root nothing under the real package is ever touched,
so nothing can be left behind.
``references=None`` stages the malformed-bundle layout instead: a bundle dir
present with no ``references/`` subdir (the hard-fail case).
"""
root.mkdir(parents=True, exist_ok=True)
# Monolith bodies and the kilo command file, so monolith-platform installs
# through the fake root keep working.
for pattern in ("skill*.md", "command-kilo.md"):
for packaged in PKG_DIR.glob(pattern):
shutil.copy2(packaged, root / packaged.name)
shutil.copytree(PKG_DIR / "always_on", root / "always_on")
shutil.copytree(PKG_DIR / "skills", root / "skills")
bundle_dir = root / "skills" / "claude"
if references is None:
shutil.rmtree(bundle_dir)
bundle_dir.mkdir(parents=True)
# Bundle dir present, but no references/ subdir inside it.
(bundle_dir / "SKILL.md").write_text("body\n", encoding="utf-8")
else:
refs_dir = bundle_dir / "references"
shutil.rmtree(refs_dir)
refs_dir.mkdir(parents=True)
(refs_dir / "extraction-spec.md").write_text(references[0], encoding="utf-8")
(refs_dir / "query.md").write_text(references[1], encoding="utf-8")
return root
def _fake_package_file(root: Path):
"""Point ``graphify.install``'s asset resolution at the staged fake root."""
return patch("graphify.install.__file__", str(root / "install.py"))
@pytest.fixture()
def fake_bundle(tmp_path):
"""Stage a fake references/ bundle in a throwaway package root.
Yields the platform name. claude is used because its skill_refs bundle is
"claude" and it has no extra plugin wiring. The fake bundle lives in a
private package root under ``tmp_path`` and the installer is pointed at it
via ``graphify.install.__file__``, so the real on-disk package (and the
checkout) is never modified (#4603).
"""
platform = _FakeBundle("claude")
platform.pkg_root = _stage_fake_package(tmp_path / "fakepkg")
with _fake_package_file(platform.pkg_root):
yield platform
def _install(tmp_path, platform):
old_cwd = Path.cwd()
try:
os.chdir(tmp_path)
with patch("graphify.__main__.Path.home", return_value=tmp_path):
mainmod.install(platform=platform)
finally:
os.chdir(old_cwd)
def test_install_stages_references_sidecar(tmp_path, fake_bundle):
"""A progressive platform install drops references/ alongside SKILL.md."""
platform = fake_bundle
_install(tmp_path, platform)
skill_dir = tmp_path / ".claude" / "skills" / "graphify"
assert (skill_dir / "SKILL.md").exists()
refs = skill_dir / "references"
assert refs.is_dir()
assert (refs / "extraction-spec.md").read_text() == "# extraction spec fragment\n"
assert (refs / "query.md").read_text() == "# query fragment\n"
# No leftover staging dir.
assert not (skill_dir / "references.tmp").exists()
def test_single_version_stamp_covers_skill_and_references(tmp_path, fake_bundle):
"""One .graphify_version stamp versions SKILL.md + references/ together."""
platform = fake_bundle
_install(tmp_path, platform)
skill_dir = tmp_path / ".claude" / "skills" / "graphify"
stamps = list(skill_dir.rglob(".graphify_version"))
assert len(stamps) == 1
assert stamps[0] == skill_dir / ".graphify_version"
assert stamps[0].read_text() == mainmod.__version__
def test_reinstall_replaces_references_atomically(tmp_path, fake_bundle):
"""Reinstall swaps references/ in place, dropping a stale fragment."""
platform = fake_bundle
_install(tmp_path, platform)
skill_dir = tmp_path / ".claude" / "skills" / "graphify"
refs = skill_dir / "references"
# Simulate a stale fragment left from an older install.
(refs / "stale-old.md").write_text("stale\n", encoding="utf-8")
assert (refs / "stale-old.md").exists()
_install(tmp_path, platform)
# The stale fragment is gone; the packaged ones are present.
assert not (refs / "stale-old.md").exists()
assert (refs / "extraction-spec.md").exists()
assert (refs / "query.md").exists()
assert not (skill_dir / "references.tmp").exists()
def test_uninstall_removes_references_then_walks_dirs(tmp_path, fake_bundle):
"""Uninstall rmtrees references/ before the dir walk so the tree is cleared."""
platform = fake_bundle
_install(tmp_path, platform)
skill_dir = tmp_path / ".claude" / "skills" / "graphify"
assert (skill_dir / "references").is_dir()
with patch("graphify.__main__.Path.home", return_value=tmp_path):
removed = mainmod._remove_skill_file(platform)
assert removed
assert not skill_dir.exists()
# The 3-level walk collapsed the now-empty skill dirs.
assert not (tmp_path / ".claude" / "skills").exists()
def test_check_skill_version_warns_on_missing_references(tmp_path, fake_bundle, capsys):
"""If SKILL.md links references/ but the dir is gone, warn to repair."""
platform = fake_bundle
_install(tmp_path, platform)
skill_dir = tmp_path / ".claude" / "skills" / "graphify"
skill = skill_dir / "SKILL.md"
# Force the body to reference the sidecar, then delete the sidecar.
skill.write_text("See references/extraction-spec.md for the schema.\n", encoding="utf-8")
shutil.rmtree(skill_dir / "references")
mainmod._check_skill_version(skill)
err = capsys.readouterr().err
assert "references/ sidecar is missing" in err
def test_check_skill_version_ignores_permission_error(tmp_path, fake_bundle, monkeypatch, capsys):
"""Unreadable version probes should not crash startup."""
platform = fake_bundle
_install(tmp_path, platform)
skill_dir = tmp_path / ".claude" / "skills" / "graphify"
skill = skill_dir / "SKILL.md"
# Drain install output so the no-warning assertions below see only what
# _check_skill_version itself emits.
capsys.readouterr()
original_exists = Path.exists
def guarded_exists(self):
if self.name == ".graphify_version":
raise PermissionError("denied")
return original_exists(self)
monkeypatch.setattr(Path, "exists", guarded_exists)
mainmod._check_skill_version(skill)
out = capsys.readouterr()
assert out.out == ""
assert out.err == ""
def test_hard_fail_when_bundle_dir_present_but_references_missing(tmp_path, monkeypatch):
"""A bundle dir that exists but has no references/ subdir is a malformed
package: exit 1 rather than silently shipping an empty sidecar.
A wholly-absent bundle dir is the opposite case (a host whose wave has not
shipped) and falls back to monolith, covered by
``test_unbuilt_bundle_host_falls_back_to_monolith``.
The malformed bundle is staged in a throwaway package root
(``_stage_fake_package(..., references=None)``), so the real package is
never modified (#4603).
"""
root = _stage_fake_package(tmp_path / "malformedpkg", references=None)
with _fake_package_file(root):
with pytest.raises(SystemExit) as exc:
with patch("graphify.__main__.Path.home", return_value=tmp_path):
monkeypatch.chdir(tmp_path)
mainmod._copy_skill_file("claude")
assert exc.value.code == 1
def _first_unbuilt_progressive_host():
"""Find a progressive host whose bundle dir has not shipped in this build.
The wave ships bundles incrementally (claude, then codex/windows, then the
rest), so this picks whichever progressive host is still bundle-less right
now instead of hard-coding one that a later wave will build. Returns the
host name and its packaged monolith path, or (None, None) if all built.
"""
skills_root = PKG_DIR / "skills"
for name, cfg in mainmod._PLATFORM_CONFIG.items():
bundle = cfg.get("skill_refs")
if not bundle:
continue
if (skills_root / bundle).exists():
continue
# Resolve the packaged monolith for this host (skill.md for claude-named).
suffix = "" if name == "claude" else f"-{name}"
monolith = PKG_DIR / f"skill{suffix}.md"
if monolith.exists():
return name, monolith
return None, None
def test_unbuilt_bundle_host_falls_back_to_monolith(tmp_path):
"""A progressive host whose bundle has not shipped installs the monolith.
claude/codex/windows bundles now ship; the remaining progressive hosts do
not have a bundle yet. They must still install their byte-identical monolith
with no references/ sidecar. The host is chosen dynamically so this stays
valid as later waves ship more bundles.
"""
host, monolith = _first_unbuilt_progressive_host()
if host is None:
pytest.skip("every progressive host bundle has shipped; nothing to fall back")
assert not (PKG_DIR / "skills" / mainmod._PLATFORM_CONFIG[host]["skill_refs"]).exists()
_install(tmp_path, host)
with patch("graphify.__main__.Path.home", return_value=tmp_path):
dst = mainmod._platform_skill_destination(host)
skill_dir = dst.parent
assert (skill_dir / "SKILL.md").exists()
assert not (skill_dir / "references").exists()
# Byte-identical to the packaged monolith for that host.
assert (skill_dir / "SKILL.md").read_bytes() == monolith.read_bytes()
def test_claude_install_ships_lean_core_and_references(tmp_path):
"""claude's real bundle ships: a lean SKILL.md plus the references/ sidecar."""
skills_claude = PKG_DIR / "skills" / "claude" / "references"
assert skills_claude.is_dir(), "claude bundle must ship in this build"
_install(tmp_path, "claude")
skill_dir = tmp_path / ".claude" / "skills" / "graphify"
skill = skill_dir / "SKILL.md"
refs = skill_dir / "references"
assert skill.exists()
assert refs.is_dir()
body = skill.read_text(encoding="utf-8")
# The installed SKILL.md is exactly the packaged lean core (skill.md IS the
# lean core now, not the old monolith).
assert body == (PKG_DIR / "skill.md").read_text(encoding="utf-8")
# The lean core points at its references and dropped the on-demand content.
assert "references/extraction-spec.md" in body
# The full embedded subagent prompt was moved out into the references.
assert '"file_type":"code|document|paper|image|rationale|concept"' not in body
# The lean core is materially smaller than the old ~1156-line monolith. The
# ceiling has a little headroom for the #4762 per-block `.graphify_root` reads
# (one line per Python block) while staying well under the monolith.
assert len(body.splitlines()) < 820
# The version stamp covers SKILL.md + references/ together.
assert (skill_dir / ".graphify_version").read_text() == mainmod.__version__
# The eight on-demand fragments all landed.
names = sorted(p.name for p in refs.glob("*.md"))
assert names == [
"add-watch.md",
"exports.md",
"extraction-spec.md",
"github-and-merge.md",
"hooks.md",
"query.md",
"transcribe.md",
"update.md",
]
def test_gemini_install_references_all_resolve(tmp_path):
"""End-to-end: every references/ pointer in gemini's installed SKILL.md resolves.
gemini ships claude's lean skill.md body but resolves its references through a
separate path, so this locks the body<->refs coupling: a real install with the
real claude bundle must leave no dead pointer on disk.
"""
import re
_install(tmp_path, "gemini")
skill = tmp_path / ".gemini" / "skills" / "graphify" / "SKILL.md"
assert skill.exists()
refdir = skill.parent / "references"
assert refdir.is_dir()
pointers = set(re.findall(r"references/([a-z-]+\.md)", skill.read_text(encoding="utf-8")))
assert pointers, "the lean core should link to references/"
missing = [p for p in pointers if not (refdir / p).is_file()]
assert not missing, f"dead reference pointers in gemini install: {missing}"
def test_claude_twins_ride_the_claude_bundle(tmp_path):
"""antigravity and kimi reuse claude's split bundle, so they go progressive too.
gemini has no _PLATFORM_CONFIG entry but installs claude's skill.md body
verbatim; that body is the lean progressive core, so gemini must ride the
claude references bundle as well or it ships a SKILL.md with dead pointers.
"""
for platform in ("antigravity", "kimi", "gemini"):
refs_src = mainmod._packaged_skill_refs_dir(platform)
assert refs_src is not None
assert refs_src == PKG_DIR / "skills" / "claude" / "references"
def test_pyproject_declares_references_globs():
"""package-data must declare the references + always-on globs that ship the bundles.
The references sidecar ships under graphify/skills/<host>/references/ and the
always-on injection blocks under graphify/always_on/. The earlier
skills/*/SKILL.md glob matched nothing (no graphify/skills/<host>/SKILL.md
exists; the skill bodies are graphify/skill*.md, listed explicitly), so it was
removed. This test guards the real shipped layout.
"""
try:
import tomllib # Python 3.11+ stdlib
except ModuleNotFoundError: # Python 3.10
import tomli as tomllib # type: ignore[no-redef]
pyproject = PKG_DIR.parent / "pyproject.toml"
if not pyproject.exists():
pytest.skip("pyproject.toml not adjacent to package (installed wheel)")
data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
pkg_data = data["tool"]["setuptools"]["package-data"]["graphify"]
assert "skills/*/references/*.md" in pkg_data
assert "always_on/*.md" in pkg_data
# The dead glob that matched no file must not creep back in.
assert "skills/*/SKILL.md" not in pkg_data
# The full progressive-disclosure payload the wheel must ship: 15 skill bodies,
# 104 references (13 split hosts x 8 each), and 6 always-on injection blocks.
_EXPECTED_SKILL_BODIES = (
"skill.md",
"skill-codex.md",
"skill-opencode.md",
"skill-kilo.md",
"skill-aider.md",
"skill-amp.md",
"skill-copilot.md",
"skill-claw.md",
"skill-windows.md",
"skill-droid.md",
"skill-trae.md",
"skill-kiro.md",
"skill-vscode.md",
"skill-pi.md",
"skill-devin.md",
)
_SPLIT_HOSTS = (
"claude", "codex", "windows", "opencode", "kilo", "copilot",
"claw", "droid", "amp", "trae", "kiro", "pi", "vscode",
)
_REFERENCE_NAMES = (
"add-watch.md", "exports.md", "extraction-spec.md", "github-and-merge.md",
"hooks.md", "query.md", "transcribe.md", "update.md",
)
_ALWAYS_ON_NAMES = (
"agents-md.md", "antigravity-rules.md", "claude-md.md",
"gemini-md.md", "kiro-steering.md", "vscode-instructions.md",
)
def _build_wheel_names(repo_root):
"""Build the wheel and return the set of arcnames inside it.
Fails loudly (not skip) when the build backend is unavailable: `build` is a
declared dev dependency, so an environment that runs this test is expected to
have it. A silent skip is how a packaging regression slips through CI.
"""
import contextlib
import hashlib
import subprocess
import sys
import tempfile
import zipfile
try:
import build # noqa: F401
except ImportError:
raise AssertionError(
"the 'build' module is required for the wheel-content test but is not "
"installed; it is a declared dev dependency (run `uv sync --all-extras`)"
)
# Serialise with the other `python -m build --no-isolation` builder
# (test_wheel_packaging): both write the checkout's shared `build/` dir, so
# two concurrent pytest runs on one checkout must not build at once (#4603).
# Keyed by repo path in the temp dir; fcntl is released by the OS on kill.
@contextlib.contextmanager
def _wheel_build_lock():
try:
import fcntl
except ImportError:
yield
return
key = hashlib.sha256(str(repo_root).encode()).hexdigest()[:16]
lock_path = Path(tempfile.gettempdir()) / f"graphify-wheel-build-{key}.lock"
with open(lock_path, "a+") as fh:
fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
try:
yield
finally:
fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
with tempfile.TemporaryDirectory() as outdir, _wheel_build_lock():
result = subprocess.run(
[sys.executable, "-m", "build", "--wheel", "--no-isolation", "--outdir", outdir, str(repo_root)],
capture_output=True,
text=True,
)
assert result.returncode == 0, (
"wheel build failed:\n"
f"stdout:\n{result.stdout[-1000:]}\n"
f"stderr:\n{result.stderr[-1000:]}"
)
wheels = list(Path(outdir).glob("*.whl"))
assert wheels, "no wheel was produced"
with zipfile.ZipFile(wheels[0]) as zf:
return set(zf.namelist())
def test_built_wheel_ships_the_full_skill_payload():
"""The built wheel must carry every skill body, reference, and always-on block.
This is the headline regression guard. If the package-data globs fail to match
(e.g. the stale skills/*/SKILL.md glob that matched nothing), the wheel ships a
SKILL.md with no references/ sidecar and an install silently loses every
on-demand fragment. The test asserts the whole shipped layout: 15 skill
bodies, 96 references, and 6 always-on injection blocks. It FAILS (not skips)
when the build backend is missing, because build is a declared dev dependency.
"""
repo_root = PKG_DIR.parent
if not (repo_root / "pyproject.toml").exists():
pytest.skip("pyproject.toml not adjacent to package (installed wheel)")
# In this wave every split-host bundle ships, so its absence is a real failure,
# not a reason to skip.
assert (PKG_DIR / "skills" / "claude" / "references" / "extraction-spec.md").exists(), (
"the claude bundle must ship in this build; the references sidecar is missing"
)
names = _build_wheel_names(repo_root)
missing_bodies = [b for b in _EXPECTED_SKILL_BODIES if f"graphify/{b}" not in names]
assert not missing_bodies, f"wheel is missing skill bodies: {missing_bodies}"
assert len(_EXPECTED_SKILL_BODIES) == 15
missing_refs = [
f"graphify/skills/{host}/references/{ref}"
for host in _SPLIT_HOSTS
for ref in _REFERENCE_NAMES
if f"graphify/skills/{host}/references/{ref}" not in names
]
assert not missing_refs, f"wheel is missing references: {missing_refs}"
assert len(_SPLIT_HOSTS) * len(_REFERENCE_NAMES) == 104
missing_always_on = [
f"graphify/always_on/{name}"
for name in _ALWAYS_ON_NAMES
if f"graphify/always_on/{name}" not in names
]
assert not missing_always_on, f"wheel is missing always-on blocks: {missing_always_on}"
assert len(_ALWAYS_ON_NAMES) == 6
# The specific headline file that the stale glob would have dropped.
assert "graphify/skills/claude/references/extraction-spec.md" in names
assert "graphify/skills/trae/references/hooks.md" in names
# amp is now a split host too; its bundle must ship like every other.
assert "graphify/skills/amp/references/hooks.md" in names
def test_monolith_install_clears_orphan_references(tmp_path, fake_bundle):
"""A monolith platform install removes any orphan references/ left behind."""
# aider is a monolith (no skill_refs). Seed an orphan references/ dir at its
# destination, then install and confirm it is cleared.
skill_dst = tmp_path / ".aider" / "graphify" / "SKILL.md"
orphan = skill_dst.parent / "references"
orphan.mkdir(parents=True)
(orphan / "leftover.md").write_text("leftover\n", encoding="utf-8")
_install(tmp_path, "aider")
assert skill_dst.exists()
assert not orphan.exists()
def test_amp_user_install_carries_references(tmp_path, monkeypatch):
"""amp is progressive: its corrected user dir also gets the references/ sidecar.
amp's real bundle now ships in the package (graphify/skills/amp/), so the
install copies the actual committed references alongside SKILL.md. This is the
case the progressive split was built to cover: amp was the omitted 13th host.
"""
from graphify.__main__ import main
assert (PKG_DIR / "skills" / "amp" / "references" / "hooks.md").exists(), (
"amp's references bundle must ship in this build"
)
home = tmp_path / "home"
project = tmp_path / "project"
project.mkdir()
monkeypatch.chdir(project)
with patch("graphify.__main__.Path.home", return_value=home):
monkeypatch.setattr(sys, "argv", ["graphify", "amp", "install"])
main()
skill_dir = home / ".config" / "agents" / "skills" / "graphify"
assert (skill_dir / "SKILL.md").exists()
# A representative reference from the shipped amp bundle lands too.
assert (skill_dir / "references" / "exports.md").exists()
assert (skill_dir / "references" / "hooks.md").exists()
monkeypatch.setattr(sys, "argv", ["graphify", "amp", "uninstall"])
main()
assert not skill_dir.exists()
def test_install_from_read_only_package_dir(tmp_path, fake_bundle):
"""Install succeeds when the packaged bundle is read-only.
Nix store paths are mode 0o555, as are root-owned site-packages and
container image layers. copytree preserves those bits onto references.tmp,
and renaming a directory needs write permission on the directory itself to
update its ".." entry — so the staging rename fails with EACCES unless the
staged copy is made writable first.
"""
platform = fake_bundle
refs_src = platform.pkg_root / "skills" / "claude" / "references"
modes = {p: p.stat().st_mode for p in (refs_src, *refs_src.rglob("*"))}
for path in modes:
path.chmod(0o555 if path.is_dir() else 0o444)
try:
_install(tmp_path, platform)
finally:
for path, mode in modes.items():
path.chmod(mode)
skill_dir = tmp_path / ".claude" / "skills" / "graphify"
refs = skill_dir / "references"
assert refs.is_dir()
assert (refs / "query.md").read_text() == "# query fragment\n"
assert not (skill_dir / "references.tmp").exists()
# The installed sidecar must stay writable, or the next install cannot
# rmtree it to swap in a new one.
assert os.access(refs, os.W_OK)
def test_install_skill_references_ignores_precreated_predictable_tmp_symlink_to_file(tmp_path):
"""A pre-created symlink at references.tmp pointing to an outside file must not be followed."""
sentinel = tmp_path / "outside" / "sentinel.txt"
sentinel.parent.mkdir(parents=True)
sentinel.write_text("sentinel-untouched\n", encoding="utf-8")
skill_dir = tmp_path / "skills" / "graphify"
skill_dir.mkdir(parents=True)
skill_dst = skill_dir / "SKILL.md"
skill_dst.write_text("skill", encoding="utf-8")
predictable_tmp = skill_dir / "references.tmp"
try:
predictable_tmp.symlink_to(sentinel)
except (OSError, NotImplementedError) as exc:
pytest.skip(f"symlink creation not permitted: {exc}")
refs_src = PKG_DIR / "skills" / "claude" / "references"
mainmod._install_skill_references(skill_dst, refs_src)
assert sentinel.read_text(encoding="utf-8") == "sentinel-untouched\n"
assert (skill_dir / "references" / "extraction-spec.md").is_file()
assert not list(skill_dir.glob(".references.*.tmp"))
def test_install_skill_references_ignores_precreated_predictable_tmp_symlink_to_dir(tmp_path):
"""A pre-created symlink at references.tmp pointing to an outside directory must not be followed or deleted."""
sentinel_dir = tmp_path / "outside_dir"
sentinel_dir.mkdir(parents=True)
sentinel_file = sentinel_dir / "sentinel.txt"
sentinel_file.write_text("sentinel-dir-untouched\n", encoding="utf-8")
skill_dir = tmp_path / "skills" / "graphify"
skill_dir.mkdir(parents=True)
skill_dst = skill_dir / "SKILL.md"
skill_dst.write_text("skill", encoding="utf-8")
predictable_tmp = skill_dir / "references.tmp"
try:
predictable_tmp.symlink_to(sentinel_dir, target_is_directory=True)
except (OSError, NotImplementedError) as exc:
pytest.skip(f"symlink creation not permitted: {exc}")
refs_src = PKG_DIR / "skills" / "claude" / "references"
mainmod._install_skill_references(skill_dst, refs_src)
assert sentinel_file.exists()
assert sentinel_file.read_text(encoding="utf-8") == "sentinel-dir-untouched\n"
assert {p.name for p in sentinel_dir.iterdir()} == {"sentinel.txt"}
assert (skill_dir / "references" / "extraction-spec.md").is_file()
assert not list(skill_dir.glob(".references.*.tmp"))
import pytest as _pytest_posix
@_pytest_posix.fixture(autouse=True)
def _posix_skill_layout(monkeypatch):
"""hermes installs to a different directory on Windows; these tests
assert the POSIX layout, so pin it."""
monkeypatch.setattr("platform.system", lambda: "Linux")