Ctrl K
ComunidadGuías…

tests/test_install_references.py

Rama v8 · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""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")