# -*- coding: utf-8 -*-
# filename: test_staging.py
# @Time    : 2026/05/24
# @Author  : JQQ
# @Email   : jqq1716@gmail.com
# @Software: PyCharm
"""
SKILL staging（mcp 源）单元测试（v0.2.1 #59）

协议依据 / Protocol: a2c-smcp-protocol docs/specification/skill.md §3（mounted/archive/resources）/ §4。

测试意图 / Test intentions:
- mounted / archive(tar.gz, zip) / resources 三模式各物化正确（文件落盘 + 注册 A2CSkillRef）
- archive sha256 不符 / 归档穿越成员 → 物化失败不入册、不抛、不逃逸
- 包根目录名校正为 frontmatter.name（§4）；缺 SKILL.md / 缺 frontmatter 必填 → 跳过
- resources 模式文本 + 二进制（blob）子资源还原正确
"""

import base64
import hashlib
import io
import logging
import tarfile
import zipfile
from collections.abc import Iterator
from pathlib import Path

import pytest
from mcp.types import BlobResourceContents, ReadResourceResult, Resource, TextResourceContents

import a2c_smcp.computer.skills.staging as staging_mod
from a2c_smcp.computer.skills.registry import SkillRegistry
from a2c_smcp.computer.skills.staging import stage_mcp_skills, stage_user_skills


# ── 测试替身 / doubles ───────────────────────────────────────────────────────
class FakeManager:
    def __init__(self, pairs: list[tuple[str, Resource]], reads: dict[str, ReadResourceResult] | None = None) -> None:
        self._pairs = pairs
        self._reads = reads or {}

    async def list_skill_resources(self, server_name: str | None = None) -> list[tuple[str, Resource]]:
        return [(s, r) for s, r in self._pairs if server_name is None or s == server_name]

    async def read_resource(self, server: str, uri: str) -> ReadResourceResult:
        return self._reads[str(uri)]


def _skill_md(name: str = "my-skill", description: str = "聚合 CSV") -> str:
    return f"---\nname: {name}\ndescription: {description}\nlicense: MIT\nallowed-tools:\n  - read\n  - write\n---\n# {name}\nbody\n"


def _root(uri: str, meta: dict) -> Resource:
    return Resource(uri=uri, name=uri.rsplit("/", 1)[-1], _meta=meta)


def _make_targz(files: dict[str, bytes]) -> bytes:
    buf = io.BytesIO()
    with tarfile.open(fileobj=buf, mode="w:gz") as tar:
        for name, data in files.items():
            info = tarfile.TarInfo(name)
            info.size = len(data)
            tar.addfile(info, io.BytesIO(data))
    return buf.getvalue()


def _make_zip(files: dict[str, bytes]) -> bytes:
    buf = io.BytesIO()
    with zipfile.ZipFile(buf, "w") as zf:
        for name, data in files.items():
            zf.writestr(name, data)
    return buf.getvalue()


def _fetch_returning(data: bytes):
    async def _fetch(url: str) -> bytes:
        return data

    return _fetch


# ── mounted ──────────────────────────────────────────────────────────────────
async def test_stage_mounted(tmp_path: Path) -> None:
    mount = tmp_path / "mount" / "my-skill"
    (mount / "scripts").mkdir(parents=True)
    (mount / "SKILL.md").write_text(_skill_md(), encoding="utf-8")
    (mount / "scripts" / "run.py").write_text("print(1)", encoding="utf-8")

    res = _root("skill://h/my-skill", {"source": "mounted", "mount_dir": str(mount), "version": "1.2.0"})
    reg = SkillRegistry()
    home = tmp_path / "home"

    names = await stage_mcp_skills(FakeManager([("tfrobot-tools", res)]), reg, home)

    assert names == ["mcp:tfrobot-tools:my-skill"]
    ref = reg.resolve("mcp:tfrobot-tools:my-skill")
    assert ref is not None
    assert ref["path"] == str(home / "mcp" / "tfrobot-tools" / "my-skill")
    assert (Path(ref["path"]) / "SKILL.md").is_file()
    assert (Path(ref["path"]) / "scripts" / "run.py").is_file()
    assert ref["source"] == "mcp:tfrobot-tools"
    assert ref["version"] == "1.2.0"
    assert ref["license"] == "MIT"
    assert ref["allowed_tools"] == ["read", "write"]
    assert ref["description"] == "聚合 CSV"


# ── archive ────────────────────────────────────────────────────────────────────
async def test_stage_archive_targz(tmp_path: Path) -> None:
    data = _make_targz({"SKILL.md": _skill_md().encode(), "scripts/run.py": b"print(1)"})
    sha = hashlib.sha256(data).hexdigest()
    res = _root(
        "skill://h/my-skill",
        {"source": "archive", "archive_uri": "https://x/s.tgz", "archive_format": "tar.gz", "archive_sha256": sha},
    )
    reg = SkillRegistry()
    home = tmp_path / "home"

    names = await stage_mcp_skills(FakeManager([("srv", res)]), reg, home, archive_fetch=_fetch_returning(data))

    assert names == ["mcp:srv:my-skill"]
    ref = reg.resolve("mcp:srv:my-skill")
    assert ref is not None
    assert (Path(ref["path"]) / "SKILL.md").is_file()
    assert (Path(ref["path"]) / "scripts" / "run.py").read_text() == "print(1)"


async def test_stage_archive_zip(tmp_path: Path) -> None:
    data = _make_zip({"SKILL.md": _skill_md(name="zskill").encode(), "ref.txt": b"x"})
    res = _root("skill://h/zskill", {"source": "archive", "archive_uri": "https://x/s.zip", "archive_format": "zip"})
    reg = SkillRegistry()
    home = tmp_path / "home"

    names = await stage_mcp_skills(FakeManager([("srv", res)]), reg, home, archive_fetch=_fetch_returning(data))

    assert names == ["mcp:srv:zskill"]
    assert (Path(reg.resolve("mcp:srv:zskill")["path"]) / "ref.txt").read_text() == "x"  # type: ignore[index]


async def test_stage_archive_sha256_mismatch_not_registered(tmp_path: Path) -> None:
    data = _make_targz({"SKILL.md": _skill_md().encode()})
    res = _root(
        "skill://h/my-skill",
        {"source": "archive", "archive_uri": "https://x/s.tgz", "archive_format": "tar.gz", "archive_sha256": "deadbeef"},
    )
    reg = SkillRegistry()
    home = tmp_path / "home"

    names = await stage_mcp_skills(FakeManager([("srv", res)]), reg, home, archive_fetch=_fetch_returning(data))

    assert names == []  # 校验失败 → 不入册、不抛
    assert len(reg) == 0


async def test_stage_archive_path_traversal_rejected(tmp_path: Path) -> None:
    # 含 ../evil 的恶意归档 → 安全解包拒绝 → 不入册、不逃逸
    data = _make_targz({"SKILL.md": _skill_md().encode(), "../evil.txt": b"pwned"})
    res = _root("skill://h/my-skill", {"source": "archive", "archive_uri": "https://x/s.tgz", "archive_format": "tar.gz"})
    reg = SkillRegistry()
    home = tmp_path / "home"

    names = await stage_mcp_skills(FakeManager([("srv", res)]), reg, home, archive_fetch=_fetch_returning(data))

    assert names == []
    assert not (tmp_path / "evil.txt").exists()  # 未逃逸到 staging 之外


async def test_stage_archive_symlink_member_rejected(tmp_path: Path) -> None:
    # 恶意 tar：含 symlink 成员（指向 /etc/passwd）→ 拒绝 → 不入册（PR 主打安全特性的回归用例）
    buf = io.BytesIO()
    with tarfile.open(fileobj=buf, mode="w:gz") as tar:
        md = _skill_md().encode()
        info = tarfile.TarInfo("SKILL.md")
        info.size = len(md)
        tar.addfile(info, io.BytesIO(md))
        link = tarfile.TarInfo("evil-link")
        link.type = tarfile.SYMTYPE
        link.linkname = "/etc/passwd"
        tar.addfile(link)
    res = _root("skill://h/my-skill", {"source": "archive", "archive_uri": "https://x/s.tgz", "archive_format": "tar.gz"})
    reg = SkillRegistry()

    names = await stage_mcp_skills(FakeManager([("srv", res)]), reg, tmp_path / "home", archive_fetch=_fetch_returning(buf.getvalue()))

    assert names == []
    assert len(reg) == 0


async def test_stage_archive_extracted_size_cap(tmp_path: Path, monkeypatch) -> None:  # noqa: ANN001
    # 解压累计超上限 → 中止 → 不入册（炸弹防护）。临时把上限调到极小以触发。
    monkeypatch.setattr(staging_mod, "MAX_EXTRACTED_BYTES", 8)
    data = _make_targz({"SKILL.md": _skill_md().encode()})  # 远超 8 字节
    res = _root("skill://h/my-skill", {"source": "archive", "archive_uri": "https://x/s.tgz", "archive_format": "tar.gz"})
    reg = SkillRegistry()
    names = await stage_mcp_skills(FakeManager([("srv", res)]), reg, tmp_path / "home", archive_fetch=_fetch_returning(data))
    assert names == []
    assert len(reg) == 0


async def test_stage_archive_download_size_cap(tmp_path: Path, monkeypatch) -> None:  # noqa: ANN001
    monkeypatch.setattr(staging_mod, "MAX_ARCHIVE_DOWNLOAD_BYTES", 4)
    data = _make_targz({"SKILL.md": _skill_md().encode()})
    res = _root("skill://h/my-skill", {"source": "archive", "archive_uri": "https://x/s.tgz", "archive_format": "tar.gz"})
    reg = SkillRegistry()
    names = await stage_mcp_skills(FakeManager([("srv", res)]), reg, tmp_path / "home", archive_fetch=_fetch_returning(data))
    assert names == []


async def test_stage_archive_member_count_cap(tmp_path: Path, monkeypatch) -> None:  # noqa: ANN001
    monkeypatch.setattr(staging_mod, "MAX_ARCHIVE_MEMBERS", 1)
    data = _make_targz({"SKILL.md": _skill_md().encode(), "a.txt": b"x"})  # 2 成员 > 1
    res = _root("skill://h/my-skill", {"source": "archive", "archive_uri": "https://x/s.tgz", "archive_format": "tar.gz"})
    reg = SkillRegistry()
    names = await stage_mcp_skills(FakeManager([("srv", res)]), reg, tmp_path / "home", archive_fetch=_fetch_returning(data))
    assert names == []


# ── resources ──────────────────────────────────────────────────────────────────
async def test_stage_resources_text_and_blob(tmp_path: Path) -> None:
    root_uri = "skill://h/my-skill"
    md_uri = "skill://h/my-skill/SKILL.md"
    bin_uri = "skill://h/my-skill/assets/logo.bin"
    root = _root(root_uri, {"source": "resources"})
    sub_md = Resource(uri=md_uri, name="SKILL.md")
    sub_bin = Resource(uri=bin_uri, name="logo.bin")
    reads = {
        md_uri: ReadResourceResult(contents=[TextResourceContents(uri=md_uri, text=_skill_md())]),
        bin_uri: ReadResourceResult(contents=[BlobResourceContents(uri=bin_uri, blob=base64.b64encode(b"\x00\x01\x02").decode())]),
    }
    mgr = FakeManager([("srv", root), ("srv", sub_md), ("srv", sub_bin)], reads=reads)
    reg = SkillRegistry()
    home = tmp_path / "home"

    names = await stage_mcp_skills(mgr, reg, home)

    assert names == ["mcp:srv:my-skill"]
    path = Path(reg.resolve("mcp:srv:my-skill")["path"])  # type: ignore[index]
    assert (path / "SKILL.md").is_file()
    assert (path / "assets" / "logo.bin").read_bytes() == b"\x00\x01\x02"


# ── 元数据 / 边界 ──────────────────────────────────────────────────────────────
async def test_frontmatter_name_canonicalizes_dir(tmp_path: Path) -> None:
    # URI 叶 old-leaf 与 frontmatter.name real-name 不一致 → 包根目录名校正为 real-name（§4）
    mount = tmp_path / "mount" / "whatever"
    mount.mkdir(parents=True)
    (mount / "SKILL.md").write_text(_skill_md(name="real-name"), encoding="utf-8")
    res = _root("skill://h/old-leaf", {"source": "mounted", "mount_dir": str(mount)})
    reg = SkillRegistry()
    home = tmp_path / "home"

    names = await stage_mcp_skills(FakeManager([("srv", res)]), reg, home)

    assert names == ["mcp:srv:real-name"]
    assert reg.resolve("mcp:srv:real-name")["path"] == str(home / "mcp" / "srv" / "real-name")  # type: ignore[index]
    assert not (home / "mcp" / "srv" / "old-leaf").exists()


async def test_missing_skill_md_not_registered(tmp_path: Path) -> None:
    mount = tmp_path / "mount" / "my-skill"
    mount.mkdir(parents=True)
    (mount / "README.md").write_text("no skill md", encoding="utf-8")
    res = _root("skill://h/my-skill", {"source": "mounted", "mount_dir": str(mount)})
    reg = SkillRegistry()
    names = await stage_mcp_skills(FakeManager([("srv", res)]), reg, tmp_path / "home")
    assert names == []
    assert len(reg) == 0


async def test_in_run_name_collision_keeps_first(tmp_path: Path) -> None:
    # 同一 run 内两个不同 SKILL 合成同 name（frontmatter.name 撞）→ 第二个按 §1.5 拒绝、保留先到者，
    # 且不覆盖先到者磁盘文件（真冲突在 rename 落盘前拦截，验证 Item 1 修复）。
    mount_a = tmp_path / "mount" / "a"
    mount_a.mkdir(parents=True)
    (mount_a / "SKILL.md").write_text(_skill_md(name="dup", description="first"), encoding="utf-8")
    (mount_a / "a.txt").write_text("A", encoding="utf-8")
    mount_b = tmp_path / "mount" / "b"
    mount_b.mkdir(parents=True)
    (mount_b / "SKILL.md").write_text(_skill_md(name="dup", description="second"), encoding="utf-8")
    (mount_b / "b.txt").write_text("B", encoding="utf-8")

    res_a = _root("skill://h/a", {"source": "mounted", "mount_dir": str(mount_a)})
    res_b = _root("skill://h/b", {"source": "mounted", "mount_dir": str(mount_b)})
    reg = SkillRegistry()
    home = tmp_path / "home"

    names = await stage_mcp_skills(FakeManager([("srv", res_a), ("srv", res_b)]), reg, home)

    assert names == ["mcp:srv:dup"]  # 仅第一个入册
    assert len(reg) == 1
    ref = reg.resolve("mcp:srv:dup")
    assert ref is not None
    assert ref["description"] == "first"  # 保留先到者
    final = home / "mcp" / "srv" / "dup"
    assert (final / "a.txt").exists()  # 先到者磁盘文件未被覆盖
    assert not (final / "b.txt").exists()


async def test_resource_without_source_meta_skipped(tmp_path: Path) -> None:
    # 无 _meta.source 的 skill:// 资源（子资源 / 未声明）→ 非 SKILL 根 → 跳过
    plain = Resource(uri="skill://h/not-a-root", name="not-a-root")
    reg = SkillRegistry()
    names = await stage_mcp_skills(FakeManager([("srv", plain)]), reg, tmp_path / "home")
    assert names == []
    assert len(reg) == 0


# ── user 源 DropIn（就地发现，不 staging，#60）────────────────────────────────
@pytest.fixture
def staging_logs(caplog: pytest.LogCaptureFixture) -> Iterator[pytest.LogCaptureFixture]:
    """捕获 staging 模块日志（项目 logger 关闭 propagate，直接挂 handler）/ capture staging logs。"""
    staging_mod.logger.addHandler(caplog.handler)
    caplog.set_level(logging.DEBUG)
    try:
        yield caplog
    finally:
        staging_mod.logger.removeHandler(caplog.handler)


def _write_user_skill(root: Path, skill_dir_name: str, *, fm_name: str | None = None, description: str = "do thing") -> Path:
    """在发现根下写一个 ``<skill_dir_name>/SKILL.md`` / write a DropIn skill dir under a root。"""
    d = root / skill_dir_name
    d.mkdir(parents=True, exist_ok=True)
    (d / "SKILL.md").write_text(_skill_md(name=skill_dir_name if fm_name is None else fm_name, description=description), encoding="utf-8")
    return d


def test_user_global_only_in_place(tmp_path: Path) -> None:
    # 仅 $A2C_SKILL_HOME/user/ 全局源：就地发现、不复制；name=basename、source=user、无 uri
    home = tmp_path / "home"
    _write_user_skill(home / "user", "alpha", description="第一")
    _write_user_skill(home / "user", "beta", description="第二")
    reg = SkillRegistry()

    names = stage_user_skills(reg, home)

    assert sorted(names) == ["alpha", "beta"]
    ref = reg.resolve("alpha")
    assert ref is not None
    assert ref["source"] == "user"
    assert "uri" not in ref  # user 源协议表面不带 skill://
    assert ref["path"] == str((home / "user" / "alpha").resolve())  # 就地包根，未复制
    assert ref["description"] == "第一"
    assert ref["license"] == "MIT"
    assert ref["allowed_tools"] == ["read", "write"]


def test_user_global_plus_multi_workdir_union(tmp_path: Path) -> None:
    # 全局 + 多 workdir 全局并集；能力层不随 active workdir 切换——传入的全部 workdir 一律生效
    home = tmp_path / "home"
    _write_user_skill(home / "user", "global-skill")
    wd1 = tmp_path / "ws1"
    wd2 = tmp_path / "ws2"
    _write_user_skill(wd1 / ".tfrobot" / "skills", "proj-a")
    _write_user_skill(wd2 / ".tfrobot" / "skills", "proj-b")
    reg = SkillRegistry()

    names = stage_user_skills(reg, home, [wd1, wd2])

    assert sorted(names) == ["global-skill", "proj-a", "proj-b"]
    # workdir skill 就地发现于各自 .tfrobot/skills，未复制进 home
    assert reg.resolve("proj-a")["path"] == str((wd1 / ".tfrobot" / "skills" / "proj-a").resolve())  # type: ignore[index]
    assert not (home / "user" / "proj-a").exists()


def test_workdir_overrides_user_home_with_warning(tmp_path: Path, staging_logs: pytest.LogCaptureFixture) -> None:
    # 同名：workspace skill 覆盖 user-home 全局（§2.3「workspace skill 覆盖 user」）+ WARN
    home = tmp_path / "home"
    _write_user_skill(home / "user", "shared", description="from-home")
    wd = tmp_path / "ws"
    _write_user_skill(wd / ".tfrobot" / "skills", "shared", description="from-workdir")
    reg = SkillRegistry()

    names = stage_user_skills(reg, home, [wd])

    assert names == ["shared"]
    assert reg.resolve("shared")["description"] == "from-workdir"  # workdir 胜  # type: ignore[index]
    assert any(r.levelno == logging.WARNING and "shadows earlier" in r.getMessage() for r in staging_logs.records)


def test_later_workdir_overrides_earlier_in_registration_order(tmp_path: Path, staging_logs: pytest.LogCaptureFixture) -> None:
    # 登记目录间同名 → 按登记序后者覆盖前者 + WARN（验收第 1 条）
    home = tmp_path / "home"
    wd1 = tmp_path / "ws1"
    wd2 = tmp_path / "ws2"
    _write_user_skill(wd1 / ".tfrobot" / "skills", "dup", description="first")
    _write_user_skill(wd2 / ".tfrobot" / "skills", "dup", description="second")
    reg = SkillRegistry()

    names = stage_user_skills(reg, home, [wd1, wd2])  # 登记序 wd1 → wd2

    assert names == ["dup"]
    assert reg.resolve("dup")["description"] == "second"  # 后登记者胜  # type: ignore[index]
    assert any(r.levelno == logging.WARNING for r in staging_logs.records)


def test_user_basename_not_kebab_skipped(tmp_path: Path, staging_logs: pytest.LogCaptureFixture) -> None:
    # 目录 basename 非严格 kebab → 跳过 + ERROR；合法兄弟仍入册（部分失败健壮）
    home = tmp_path / "home"
    _write_user_skill(home / "user", "Bad_Name")  # 含大写 + 下划线 → 非 kebab
    _write_user_skill(home / "user", "good-one")
    reg = SkillRegistry()

    names = stage_user_skills(reg, home)

    assert names == ["good-one"]
    assert len(reg) == 1
    assert any(r.levelno == logging.ERROR and "name invalid" in r.getMessage() for r in staging_logs.records)


def test_user_deeper_skill_md_ignored_with_debug(tmp_path: Path, staging_logs: pytest.LogCaptureFixture) -> None:
    # 深于一级的 SKILL.md（<root>/a/b/SKILL.md）→ 忽略 + DEBUG；根下一级的仍发现（验收第 2 条）
    home = tmp_path / "home"
    root = home / "user"
    _write_user_skill(root, "ok-skill")
    nested = root / "wrapper" / "inner"
    nested.mkdir(parents=True)
    (nested / "SKILL.md").write_text(_skill_md(name="inner", description="too deep"), encoding="utf-8")
    reg = SkillRegistry()

    names = stage_user_skills(reg, home)

    assert names == ["ok-skill"]  # inner（二级）被忽略
    assert any(r.levelno == logging.DEBUG and "not at one-level depth" in r.getMessage() for r in staging_logs.records)


def test_user_missing_description_skipped(tmp_path: Path, staging_logs: pytest.LogCaptureFixture) -> None:
    # frontmatter 缺 description（A2CSkillRef 必填）→ 跳过 + ERROR
    home = tmp_path / "home"
    d = home / "user" / "no-desc"
    d.mkdir(parents=True)
    (d / "SKILL.md").write_text("---\nname: no-desc\n---\n# body\n", encoding="utf-8")
    reg = SkillRegistry()

    names = stage_user_skills(reg, home)

    assert names == []
    assert len(reg) == 0
    assert any(r.levelno == logging.ERROR and "missing required 'description'" in r.getMessage() for r in staging_logs.records)


def test_user_frontmatter_name_mismatch_basename_wins(tmp_path: Path, staging_logs: pytest.LogCaptureFixture) -> None:
    # frontmatter.name 与目录 basename 不一致 → basename 权威（就地不可改名）+ DEBUG
    home = tmp_path / "home"
    _write_user_skill(home / "user", "real-dir", fm_name="other-name")
    reg = SkillRegistry()

    names = stage_user_skills(reg, home)

    assert names == ["real-dir"]  # 目录 basename 胜，非 frontmatter.name
    assert reg.resolve("other-name") is None
    assert any(r.levelno == logging.DEBUG and "basename is authoritative" in r.getMessage() for r in staging_logs.records)


def test_user_rescan_idempotent_updates(tmp_path: Path) -> None:
    # 重扫幂等：已注册 → update（不重复、不报错），内容变更被刷新
    home = tmp_path / "home"
    skill = _write_user_skill(home / "user", "iter-skill", description="v1")
    reg = SkillRegistry()

    first = stage_user_skills(reg, home)
    assert first == ["iter-skill"]
    assert reg.resolve("iter-skill")["description"] == "v1"  # type: ignore[index]

    (skill / "SKILL.md").write_text(_skill_md(name="iter-skill", description="v2"), encoding="utf-8")
    second = stage_user_skills(reg, home)

    assert second == ["iter-skill"]
    assert len(reg) == 1  # 未重复注册
    assert reg.resolve("iter-skill")["description"] == "v2"  # 刷新生效  # type: ignore[index]


def test_user_missing_roots_tolerated(tmp_path: Path) -> None:
    # 发现根不存在（home/user 与 workdir 都没建）→ 返回空、不抛
    home = tmp_path / "nonexistent-home"
    reg = SkillRegistry()
    names = stage_user_skills(reg, home, [tmp_path / "no-such-ws"])
    assert names == []
    assert len(reg) == 0


def test_user_skill_md_unreadable_skipped(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, staging_logs: pytest.LogCaptureFixture) -> None:
    # 发现后 SKILL.md 不可读（TOCTOU）→ _build_user_ref 的 OSError 分支 → 跳过 + ERROR、不抛
    home = tmp_path / "home"
    _write_user_skill(home / "user", "toctou")
    reg = SkillRegistry()

    orig_read = Path.read_text

    def boom(self: Path, *a: object, **k: object) -> str:
        if self.name == "SKILL.md":
            raise OSError("simulated unreadable after discovery (TOCTOU)")
        return orig_read(self, *a, **k)  # type: ignore[arg-type]

    monkeypatch.setattr(Path, "read_text", boom)  # 发现走 is_file()，不读；仅 _build_user_ref 读 → 触发分支

    names = stage_user_skills(reg, home)

    assert names == []
    assert len(reg) == 0
    assert any(r.levelno == logging.ERROR and "unreadable" in r.getMessage() for r in staging_logs.records)


def test_user_dropin_roots_dedup_no_spurious_warning(tmp_path: Path, staging_logs: pytest.LogCaptureFixture) -> None:
    # 同一 workdir 登记两次 → _user_dropin_roots 按解析路径去重 → 只扫一次、不产生「shadows earlier」假 WARN
    home = tmp_path / "home"
    wd = tmp_path / "ws"
    _write_user_skill(wd / ".tfrobot" / "skills", "uniq")
    reg = SkillRegistry()

    names = stage_user_skills(reg, home, [wd, wd])

    assert names == ["uniq"]
    assert len(reg) == 1
    assert not any(r.levelno == logging.WARNING for r in staging_logs.records)
