Ctrl K
ComunidadGuías…

tests/test_repo_hygiene.py

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

Archivo original ↗Preguntar a KiBot
"""仓库与顶层目录的整洁门槛。

检查顶层布局、工作树登记、私有产物和公开文档中的个人信息。
"""
import ast
import pathlib
import re
import socket
import subprocess
import tempfile
import tomllib
import unittest

import peach
from peach import appid
from scripts import test_runner

REPO = pathlib.Path(peach.__file__).resolve().parents[2]
DOCS = REPO / "docs"

#: ADR-0017 定义的四个运行时目录,加一个归置处。顶层只允许这五项。
TOP_LEVEL_ALLOWED = {"peach-app", "peach-data", "peach-sync", "peach-worktrees", "attic"}

#: 文件管理器自己写的缓存文件,按 casefold 比对(Windows 上大小写会变)。门槛拦的是人
#: 放上去的散落文档和产物;这几个不是人放的,macOS Finder 只要打开过那个文件夹就会写
#: `.DS_Store`,Windows 资源管理器写 `desktop.ini` 与 `Thumbs.db`,删掉照样再长出来。
#: 不豁免它们,这条门槛在任何有图形界面的开发机上都是红的,红得和布局无关。
TOP_LEVEL_GENERATED_FILES = frozenset({".ds_store", "desktop.ini", "thumbs.db"})

PRIVATE_CONTEXT_DOCUMENTS = frozenset({
    "HANDOFF.md", "STATUS.md", "PRODUCT_BACKLOG.md", "VERIFICATION_EFFICIENCY.md",
    "UI_REUSE_REVIEW.md", "SCRAPING_AUDIT.md",
})
PRIVATE_CONTEXT_PATHS = frozenset({
    "docs/reference-snapshots/tag-vocabulary-audit.md", "docs/scraping-audit.csv",
})
PRIVATE_ARTIFACT_ROOTS = frozenset({
    "peach-data", "peach-sync", "peach-worktrees", "secrets", "generated", "review", "attic", "build",
})
PRIVATE_RECORD_PATH = re.compile(r"(?:peach-data/(?:review|archive)/|attic/(?:evidence|reviews)/)(?![<*])[^`\s)]+")
COLLECTION_INVENTORY = re.compile(
    r"(?:本机|本库|真实馆藏|真实库|库里|馆里)[^。\n]{0,100}\d[\d,]*\s*"
    r"(?:条资产|行|部|作品|张头像|张封面|个文件|条来源|个题材|枚圆标|位缺头像)"
)


def _private_artifact(name: str) -> bool:
    path = pathlib.PurePosixPath(name)
    if path.parts[0].casefold() in PRIVATE_ARTIFACT_ROOTS:
        return True
    if name.startswith((".claude/worktrees/", ".codex/environments/")):
        return True
    if name in PRIVATE_CONTEXT_PATHS:
        return True
    if path.parent == pathlib.PurePosixPath("docs") and path.name in PRIVATE_CONTEXT_DOCUMENTS:
        return True
    if path.name == ".env" or path.name.startswith(".env.") and path.name != ".env.example":
        return True
    return bool(re.search(r"\.(?:db|sqlite\d*)(?:-(?:wal|shm))?$", path.name, re.I))


class PrivateArtifactTests(unittest.TestCase):
    """公开代码树拒收私有上下文、账本与运行产物。"""

    def test_private_artifact_paths_are_rejected_and_public_sources_are_allowed(self):
        for name in ("docs/HANDOFF.md", "docs/STATUS.md", "docs/PRODUCT_BACKLOG.md",
                     "peach-data/review/catalog.csv", "review/current-inventory.csv",
                     "attic/evidence/session.json", "secrets/access.json", ".env.local",
                     "database/ledger.db-wal", "database/history.sqlite3"):
            with self.subTest(path=name):
                self.assertTrue(_private_artifact(name))
        for name in ("docs/REUSE.md", "docs/OPERATIONS.md", "docs/TESTING.md",
                     "tests/test_job_status.py", "web/dist/peach-app.js", ".env.example",
                     "resources/seed/entities.json"):
            with self.subTest(path=name):
                self.assertFalse(_private_artifact(name))

    def test_tracked_files_exclude_private_artifacts(self):
        done = subprocess.run(["git", "ls-files", "-z"], cwd=REPO, capture_output=True, check=True)
        names = done.stdout.decode("utf-8").split("\0")
        offenders = [name for name in names if name and (REPO / name).is_file() and _private_artifact(name)]
        self.assertEqual(offenders, [], "私有记录与运行产物必须保存在代码仓库外")

    def test_git_ignores_private_context_and_runtime_directories(self):
        names = [f"docs/{name}" for name in sorted(PRIVATE_CONTEXT_DOCUMENTS)]
        names += sorted(PRIVATE_CONTEXT_PATHS)
        names += ["peach-data/review/catalog.csv", "secrets/access.json", "review/catalog.csv",
                  "attic/evidence/session.json"]
        public_sources = ["frontend/src/react/review/review-page.tsx", "resources/seed/entities.json"]
        done = subprocess.run(["git", "check-ignore", "--no-index", "-z", "--stdin"], cwd=REPO,
                              input=("\0".join([*names, *public_sources]) + "\0").encode("utf-8"),
                              capture_output=True, check=False)
        self.assertEqual(done.returncode, 0, done.stderr.decode("utf-8"))
        self.assertEqual(set(done.stdout.decode("utf-8").strip("\0").split("\0")), set(names))

    def test_private_inventory_and_record_references_are_distinguished_from_examples(self):
        self.assertIsNotNone(COLLECTION_INVENTORY.search("本机盘点有 1234 部作品"))
        self.assertIsNotNone(PRIVATE_RECORD_PATH.search("peach-data/review/current-catalog.csv"))
        self.assertIsNone(COLLECTION_INVENTORY.search("示例库包含 12 部合成视频"))
        self.assertIsNone(PRIVATE_RECORD_PATH.search("<Peach 数据目录>/review/<复核产物>"))


def _loose_files(top: pathlib.Path) -> list[str]:
    """顶层的散落文件名,去掉文件管理器自己写的缓存。"""
    return sorted(child.name for child in top.iterdir() if child.is_file()
                  and child.name.casefold() not in TOP_LEVEL_GENERATED_FILES)


class TopLevelLayoutTests(unittest.TestCase):
    """顶层只放四个运行时目录加 attic/,且不放散落文件。

    在别的机器或 CI 上这个布局不成立(只 clone 了 peach-app),那里跳过——门槛要能在
    真实布局上拦住人,不能因为环境不同就把整套测试拖红。
    """

    def setUp(self):
        # 往上找真正的顶层,而不是 `REPO.parent`:测试几乎总是在隔离工作树里跑,那时
        # 上一级是 `peach-worktrees/`,判据不成立就会永远跳过——一个永远跳过的门槛
        # 等于没有,和它要防的失效是同一种。
        self.top = None
        for candidate in [REPO, *REPO.parents]:
            if all((candidate / name).is_dir()
                   for name in ("peach-app", "peach-data", "peach-worktrees")):
                self.top = candidate
                break
        if self.top is None:
            self.skipTest("不是双盘运行时布局(只 clone 了 peach-app),跳过")

    def test_only_the_runtime_directories_and_attic_live_at_the_top(self):
        extra = sorted(p.name for p in self.top.iterdir()
                       if p.is_dir() and p.name not in TOP_LEVEL_ALLOWED)
        self.assertEqual(
            extra, [],
            "顶层多了目录:`peach-` 前缀专属那四个运行时目录,别的东西进 attic/ 的"
            " builds/evidence/instances/tools/reviews,目录名写成 YYYYMMDD-主题",
        )

    def test_no_loose_files_at_the_top(self):
        self.assertEqual(_loose_files(self.top), [],
                         "顶层不放散落文件:文档进 attic/reviews/,产物进 attic/evidence/")


class LooseFileGuardTests(unittest.TestCase):
    """散落文件的判据本身。不依赖这台机器的布局,所以在哪儿都跑。"""

    def test_the_guard_exempts_only_the_file_manager_caches(self):
        """门槛自身也要能被证伪:缓存放行,人放上去的文件照旧算违规。"""
        with tempfile.TemporaryDirectory() as raw:
            sample = pathlib.Path(raw).resolve()
            for name in (".DS_Store", "desktop.ini", "Thumbs.db",
                         "REVIEW.md", "probe.json", "ds_store.md"):
                (sample / name).write_bytes(b"x")
            (sample / "peach-app").mkdir()
            self.assertEqual(_loose_files(sample),
                             ["REVIEW.md", "ds_store.md", "probe.json"])


class UnregisteredWorktreeTests(unittest.TestCase):
    """两个工作树落点里都不许留下未登记的目录。

    工作树被回收后目录可能留在原地,成了一份看不出区别的旧副本——在里面跑 git
    全部作用于主检出的 master。`peach-worktrees/` 是 `create` 的落点,也是智能体被告知
    要去干活的地方,所以它和 `.claude/worktrees/` 同一条判据。别的会话此刻可能正合法地
    占着一个工作树,所以判据是「有没有登记」而不是「有没有目录」,登记过的放行,
    本测试也从不删东西。
    """

    def _git(self, *args: str) -> str:
        done = subprocess.run(["git", *args], cwd=REPO, capture_output=True,
                              text=True, encoding="utf-8", errors="replace", check=False)
        if done.returncode != 0:
            self.skipTest(f"git 不可用或不是仓库:{done.stderr.strip()}")
        return done.stdout

    def test_no_unregistered_directory_lingers_in_either_worktree_root(self):
        # `--git-common-dir` 在工作树里指向主检出的 `.git`,在主检出里是相对路径,
        # 所以统一按 REPO 解析再取上一级。只 clone 了 peach-app 的机器上两个落点都可能
        # 不存在,不存在的那一个跳过。
        common = pathlib.Path(self._git("rev-parse", "--git-common-dir").strip())
        main = (REPO / common).resolve().parent
        registered = {
            pathlib.Path(line[len("worktree "):]).resolve()
            for line in self._git("worktree", "list", "--porcelain").splitlines()
            if line.startswith("worktree ")
        }
        for root in (main.parent / "peach-worktrees", main / ".claude" / "worktrees"):
            if not root.is_dir():
                continue
            residue = sorted(child.name for child in root.iterdir()
                             if child.is_dir() and child.resolve() not in registered)
            self.assertEqual(
                residue, [],
                f"{root} 下有未登记的工作树残留:`prune --apply` 扫空目录,"
                "里面还有文件的按它报的路径确认没人在用后手动删",
            )


class BundledImageTests(unittest.TestCase):
    """图像二进制只许出现在 `resources/` 下。

    ADR-0024 的验收清单里一直有这一条,没有实现。ADR-0026 划出「法人实体标识」这条窄
    例外之后它更要有:例外一旦开口,下一张图进来时挡住它的只能是门槛,不是那份 ADR
    的措辞。`resources/` 之内由 `test_brand_marks` 逐条查判据,这里只管边界。
    """

    #: 判扩展名而不判字节:仓库里没有无扩展名的图,而读全部文件去嗅探要慢两个数量级。
    IMAGE_SUFFIXES = frozenset({
        ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".avif", ".ico", ".icns", ".tiff",
    })

    def test_images_live_only_under_resources(self):
        done = subprocess.run(["git", "ls-files", "-z"], cwd=REPO, capture_output=True,
                              text=True, encoding="utf-8", errors="replace", check=False)
        if done.returncode != 0:
            self.skipTest(f"git 不可用或不是仓库:{done.stderr.strip()}")
        stray = sorted(
            name for name in done.stdout.split("\0")
            if name and pathlib.PurePosixPath(name).suffix.lower() in self.IMAGE_SUFFIXES
            and not name.startswith("resources/")
        )
        self.assertEqual(
            stray, [],
            "图像二进制只进 resources/:应用自身品牌资产直接放 resources/,"
            "厂牌与站点标识走 scripts/sync_brand_marks.py 收进 resources/marks/(ADR-0026),"
            "其余图片是本机派生产物,留在 peach-data/generated/",
        )


class DocumentationMediaTests(unittest.TestCase):
    """`docs/assets/` 只放少量演示媒体,且每个文件都要小到能进 Git。

    README 的介绍视频住在这里,所以这一档不是「不许有字节」而是「有上限」:没有上限的话,
    下一段素材进来时挡住它的只剩人的自觉,而仓库一旦收下几十 MB 的二进制,clone 的人
    每次都得付这笔钱,删掉也只是从工作区消失,历史里照样躺着。
    """

    ASSETS = DOCS / "assets"
    #: 只认这几种容器:都是浏览器与 GitHub 直接播得动的格式,不需要额外转码说明。
    MEDIA_SUFFIXES = frozenset({".mp4", ".webm"})
    MAX_BYTES = 8 * 1024 * 1024

    def test_demo_media_stays_within_its_allowed_types_and_size(self):
        if not self.ASSETS.is_dir():
            self.skipTest("docs/assets 不存在")
        for path in sorted(self.ASSETS.rglob("*")):
            if not path.is_file():
                continue
            with self.subTest(asset=path.name):
                self.assertIn(
                    path.suffix.lower(), self.MEDIA_SUFFIXES,
                    "docs/assets 只收 mp4 与 webm 演示媒体;截图与图标走 resources/",
                )
                self.assertLessEqual(
                    path.stat().st_size, self.MAX_BYTES,
                    f"{path.name} 超过 {self.MAX_BYTES // (1024 * 1024)} MB:"
                    "演示媒体要先压到这个上限以内,压不下去就别放进仓库",
                )




#: 家目录路径:`C:\Users\<名字>\…`、`/Users/<名字>/…`、`/home/<名字>/…`。名字段要以字母开头,
#: `<user>`、`<用户目录>` 这类占位由此放行;前面不许紧贴单词字符,`pixiv.net/users/…` 这种
#: URL 路径不算家目录。CI 靶机的 `runner`/`runneradmin`(含 8.3 短名 `RUNNER~1`)是文档里
#: 会正当提到的名字,放行。
_HOME_DIRECTORY = (r"(?<!\w)[\\/](?:Users|home)[\\/]"
                   r"(?!(?:runner|runneradmin)\b)[A-Za-z][\w.-]*")

#: mDNS 主机名 `<名字>.local`。只放行三个有文档意义的占位:默认名 `peach` 与讲双机布局用的
#: `peach-writer`/`peach-reader`。`self.local`/`this.local` 是属性名与后缀同形,不算主机名;
#: `threading.local()` 是标准库调用;同名主机地址仍按机器坐标检查。
#: `<mounts.local>` 是占位;`settings.local.json`、`_tcp.local.` 后面还有段,都不是主机名。
_MDNS_HOST = (r"(?<![\w<-])(?!(?:self|this|peach|peach-writer|peach-reader)\.local\b|threading\.local\s*\()"
              r"[a-z0-9-]+\.local\b(?![.\w])")

#: RFC 1918 私网地址;`10.` 段收尾要词边界,Windows 版本号 `10.0.26200.1234` 才不会被算进去。
#: 举例用 RFC 5737 的 192.0.2.0/24、198.51.100.0/24、203.0.113.0/24:它们不属于任何人,
#: 读者也不会照抄进自己的配置。
_PRIVATE_IPV4 = (r"\b192\.168\.\d{1,3}\.\d{1,3}"
                 r"|\b10\.\d{1,3}\.\d{1,3}\.\d{1,3}\b"
                 r"|\b172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}")

#: 只属于某一台机器的字面量。它们要么住在设置文件里,要么由用户在命令行给,
#: 不能编译进 `src/peach/`——否则第一个陌生用户跑起来就连着别人家的坐标(ADR-0023)。
#: 挂载点与项目位置只在源码里拦:说明文档要讲清双机布局,就得点出具体落点。
PERSONAL_LITERAL = re.compile("(?i)" + "|".join((
    r"Desktop[\\/]peach",                       # 某一台机器的项目位置
    _HOME_DIRECTORY,
    _MDNS_HOST,
    r"Volumes[\\/](?:peach-sync|RESOURCES)",    # macOS 上的具体挂载点
    r"[\\/]IMSL[\\/]",                          # CloudDrive 挂载目录
    _PRIVATE_IPV4,
)))

#: 全树一律不许出现的机器坐标:私网地址的具体一台、任何人的家目录、任何一台机器的 mDNS 名。
#: 仓库公开后它们既是别人家的坐标又是个人信息(ADR-0023 第四阶段)。判据写成形状而不是
#: 点名:门槛自己也进 Git,点名等于把要拦的坐标印在公开代码里。当前维护者的账号名与
#: 主机名由 `MachineCoordinateTests` 运行时取本机的值再扫一遍,对任何维护者都成立;
#: 那一遍先剔掉仓库自己的公开归属,判据见 `_this_machine_guard`。
#:
#: 判据是「只对某一台机器成立」,不是「像个名字」:仓库的 GitHub 归属与 LICENSE 的
#: 版权人本来就要公开署名,它们不在拦截范围内。
MACHINE_COORDINATE = re.compile("(?i)" + "|".join((_PRIVATE_IPV4, _HOME_DIRECTORY, _MDNS_HOST)))

#: 从 GitHub 的仓库 URL 里取归属名,`https://github.com/<owner>/<repo>` 与
#: `git@github.com:<owner>/<repo>` 两种写法都收。
_GITHUB_OWNER = re.compile(r"github\.com[:/]+([A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)/")

#: 门槛自身要写出它拦的形状,所以只有它自己豁免。
COORDINATE_EXEMPT_FILES = frozenset({"tests/test_repo_hygiene.py"})

#: 第三方代码与构建产物的措辞不由本仓库决定,压缩后的它们也没有可读的行。
#: 上游 README 快照逐字节锁在 `docs/reference-sources.json` 的 sha256 上,示例地址属于上游。
COORDINATE_EXEMPT_PREFIXES = ("web/vendor/", "web/dist/", "docs/reference-snapshots/upstream/")


def _live_strings(path: pathlib.Path) -> list[tuple[int, str]]:
    """源码里真正会被当值用的字符串常量,去掉文档串。

    文档串和注释留给解释:说明「哪一台机器是 writer」时提到具体名字是有信息量的,
    把它编译成默认值才是问题。判据因此是 AST 而不是 grep。
    """
    tree = ast.parse(path.read_text(encoding="utf-8"))
    docstrings = {
        id(node.body[0].value)
        for node in ast.walk(tree)
        if isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef,
                             ast.AsyncFunctionDef))
        and node.body and isinstance(node.body[0], ast.Expr)
        and isinstance(node.body[0].value, ast.Constant)
        and isinstance(node.body[0].value.value, str)
    }
    return [(node.lineno, node.value) for node in ast.walk(tree)
            if isinstance(node, ast.Constant) and isinstance(node.value, str)
            and id(node) not in docstrings]


def _repository_identity(repo: pathlib.Path = REPO) -> frozenset[str]:
    """仓库自己的公开归属名,casefold 过。

    三个来源都取,取不到的静默跳过:`git remote` 的 URL、`pyproject.toml` 的项目 URL、
    `src/peach/appid.py` 的反向域名前缀。它们指的是同一个归属,多取一处是为了任一处
    缺失时判据仍然成立,而不是把名字写进这里——门槛自己也进 Git,点名等于把维护者的
    账号名印进公开代码,和形状判据不点名坐标是同一个理由。
    """
    names = set(appid.BUNDLE_PREFIX.split("."))
    urls: list[str] = []
    with (repo / "pyproject.toml").open("rb") as handle:
        urls += tomllib.load(handle)["project"].get("urls", {}).values()
    done = subprocess.run(["git", "remote", "-v"], cwd=repo, capture_output=True,
                          text=True, encoding="utf-8", errors="replace", check=False)
    if done.returncode == 0:
        urls += done.stdout.split()
    for url in urls:
        found = _GITHUB_OWNER.search(url)
        if found:
            names.add(found.group(1))
    return frozenset(name.casefold() for name in names if name)


def _this_machine_guard(names, identity) -> re.Pattern[str] | None:
    """只在网址、挂载目录和账号/主机配置中匹配本机名字。

    仓库公开归属不属于个人坐标;用户目录与 mDNS 等坐标仍由 MACHINE_COORDINATE
    检查。无剩余名字时返回 None,避免空正则匹配全部内容。
    """
    remaining = sorted(name for name in names if name and name.casefold() not in identity)
    if not remaining:
        return None
    names_pattern = r"(?:" + "|".join(map(re.escape, remaining)) + r")"
    context = (r"(?:https?://|smb://|\\\\|/Volumes/|/mnt/|/media/)"
               r"|\b(?:username|user_name|hostname|smb_host|account)['\"]?\s*[:=]\s*['\"]?")
    return re.compile("(?:" + context + ")" + names_pattern + r"(?![\w.-])", re.IGNORECASE)


class PersonalLiteralTests(unittest.TestCase):
    """`src/peach/` 里不许留只对某一台机器成立的默认值。"""

    def test_common_machine_names_require_coordinate_context(self):
        guard = _this_machine_guard({"test", "desktop"}, frozenset())
        for text in ("test the desktop app", "https://example.invalid/test", "desktop-client", "accounting test"):
            self.assertIsNone(guard.search(text), text)
        for text in ("https://desktop/media", "smb://test/share", r"\\desktop\share",
                     "/Volumes/test/media", "hostname = 'desktop'", "username: test",
                     '{"hostname": "desktop"}', '{"username": "test"}'):
            self.assertIsNotNone(guard.search(text), text)

    def test_no_module_hardcodes_a_personal_coordinate(self):
        source = pathlib.Path(peach.__file__).parent
        offenders = []
        for path in sorted(source.rglob("*.py")):
            offenders += [
                f"{path.relative_to(source).as_posix()}:{line}:{text[:60]}"
                for line, text in _live_strings(path)
                if PERSONAL_LITERAL.search(text)
            ]
        self.assertEqual(offenders, [],
                         "把它搬进 <Peach 数据目录>/config.toml(peach init --from-existing 会生成),"
                         "或者做成命令行参数")

    def test_the_guard_catches_the_shape_it_describes(self):
        """门槛自身也要能被证伪,否则它可能只是一段永远为真的代码。"""
        for sample in (r"C:\Users\alice\Desktop\peach\peach-data", "~/Desktop/peach",
                       "/Users/alice/peach", "/home/alice/peach", "alice-mbp.local",
                       "/Volumes/peach-sync", "https://10.234.56.78",
                       "/Volumes/RESOURCES/media", "/Users/x/Desktop/IMSL/115"):
            self.assertTrue(PERSONAL_LITERAL.search(sample), sample)
        for allowed in ("127.0.0.1", "0.0.0.0", "peach.local", "224.0.0.251",
                        "192.0.2.1", r"R:\media", "peach-data", "peach-sync",
                        "Chrome/131.0.0.0", r"C:\Users\<user>\peach-data",
                        "https://www.pixiv.net/users/93812377", "self.local"):
            self.assertIsNone(PERSONAL_LITERAL.search(allowed), allowed)


class ReleaseFilesTests(unittest.TestCase):
    """公开发布必须齐备的治理文件(ADR-0023 第 4 阶段,名称与标识政策见 ADR-0129)。

    仓库一旦公开,许可证、名称政策、贡献说明、安全说明和 issue/PR 模板就是陌生人判断「能不能用、
    怎么报问题」的唯一依据。缺一份的后果不是报错而是沉默:使用者无从判断授权范围,
    漏洞只能开成公开 issue。它们又都是「一次写好、之后没人再看」的文件,正是重构和
    路径调整时最容易被顺手删掉的那一类,所以在这里钉住存在性。
    """

    #: 相对仓库根的路径。清单只增不减,删除任何一项都要先改 ADR-0023。
    REQUIRED = (
        "LICENSE",
        "docs/TRADEMARKS.md",
        "CONTRIBUTING.md",
        "SECURITY.md",
        "CHANGELOG.md",
        ".github/PULL_REQUEST_TEMPLATE.md",
        ".github/ISSUE_TEMPLATE/config.yml",
    )

    def test_every_release_file_exists_and_has_content(self):
        missing = [name for name in self.REQUIRED if not (REPO / name).is_file()]
        self.assertEqual(missing, [], "公开发布缺少治理文件")
        empty = [name for name in self.REQUIRED
                 if not (REPO / name).read_text(encoding="utf-8").strip()]
        self.assertEqual(empty, [], "治理文件存在但是空的,等于没有")

    def test_the_licence_is_the_agpl_v3(self):
        text = (REPO / "LICENSE").read_text(encoding="utf-8")
        for marker in ("GNU AFFERO GENERAL PUBLIC LICENSE", "Version 3"):
            self.assertIn(marker, text,
                          "许可证是 AGPL-3.0-or-later(ADR-0023 第 4 阶段),换许可证要先改 ADR")

    def test_pyproject_licence_expression_matches_the_licence_file(self):
        """pyproject 的 SPDX 表达式和 LICENSE 全文是同一份许可证的两种写法。"""
        with (REPO / "pyproject.toml").open("rb") as handle:
            project = tomllib.load(handle)["project"]
        self.assertEqual(
            project.get("license"), "AGPL-3.0-or-later",
            "pyproject.toml 的 SPDX 表达式必须与 LICENSE 全文和 ADR-0023 第 4 阶段一致;"
            "换许可证要 LICENSE、pyproject.toml、ADR-0023 三处同改",
        )


class MachineCoordinateTests(unittest.TestCase):
    """Git 跟踪的每个文本文件都不许写出某一台机器的坐标。

    只管源码的门槛拦不住这件事:真实 IP、用户名和账号名过去散在测试夹具、安装脚本、
    ADR、状态文档与技能里,它们都不是「默认值」,公开仓库后却一样把用户的网络布局
    和账号名交出去。所以判据是 `git ls-files` 的全部文本,不是某个目录。
    """

    def _tracked_text_files(self, exempt_files=COORDINATE_EXEMPT_FILES):
        done = subprocess.run(["git", "ls-files", "-z"], cwd=REPO,
                              capture_output=True, check=False)
        if done.returncode != 0:
            self.skipTest(f"git 不可用或不是仓库:{done.stderr.decode('utf-8', 'replace')}")
        for name in done.stdout.decode("utf-8").split("\0"):
            if not name or name in exempt_files:
                continue
            if name.startswith(COORDINATE_EXEMPT_PREFIXES):
                continue
            path = REPO / name
            if not path.is_file():
                continue
            raw = path.read_bytes()
            if b"\0" in raw:
                continue
            try:
                yield name, raw.decode("utf-8")
            except UnicodeDecodeError:
                continue

    def test_no_tracked_file_names_a_machine_coordinate(self):
        offenders = []
        for name, text in self._tracked_text_files():
            for number, line in enumerate(text.splitlines(), 1):
                found = MACHINE_COORDINATE.search(line)
                if found:
                    offenders.append(f"{name}:{number}:{found.group(0)}")
        self.assertEqual(
            offenders, [],
            "换成 RFC 5737 的文档地址(192.0.2.0/24、198.51.100.0/24、203.0.113.0/24)"
            "或中性的主机名、账号名、`<用户目录>` 这类占位",
        )

    def test_public_documents_exclude_collection_inventories_and_private_record_names(self):
        offenders = []
        for name, text in self._tracked_text_files():
            if not name.endswith(".md"):
                continue
            for number, line in enumerate(text.splitlines(), 1):
                if COLLECTION_INVENTORY.search(line) or PRIVATE_RECORD_PATH.search(line.replace("\\", "/")):
                    offenders.append(f"{name}:{number}")
        self.assertEqual(offenders, [], "馆藏盘点与私有记录名应保存在部署 Peach 数据目录,检查输出只列文件和行号")

    def test_no_tracked_file_names_this_machine(self):
        """本机的账号名与主机名对任何维护者都成立,正好不用在正则里点名。

        扫的是「剔掉仓库自身归属之后剩下的本机名字」,判据与剔除的理由见
        `_this_machine_guard`。本文件自己也扫:形状门槛豁免它是因为它要写出形状,
        可它没有理由写出这台机器。
        """
        hostname = socket.gethostname()
        names = {pathlib.Path.home().name, hostname, hostname.split(".", 1)[0]}
        this_machine = _this_machine_guard(names, _repository_identity())
        if this_machine is None:
            self.skipTest("本机的账号名与主机名都是仓库自己的公开归属,只剩形状门槛可扫")
        offenders = []
        for name, text in self._tracked_text_files(exempt_files=frozenset()):
            for number, line in enumerate(text.splitlines(), 1):
                found = this_machine.search(line)
                if found:
                    offenders.append(f"{name}:{number}:{found.group(0)}")
        self.assertEqual(offenders, [], "换成中性的账号名、主机名或 `<用户目录>` 这类占位")

    def test_the_whole_tree_guard_catches_the_shape_it_describes(self):
        """门槛自身也要能被证伪,否则它可能只是一段永远为真的代码。"""
        for sample in ("https://10.234.56.78", "PEACH_SHARED_SMB_HOST=192.168.1.9",
                       "review_writer_origin = 'https://10.0.0.5'", "172.31.112.1",
                       r"C:\Users\alice\Desktop\peach", "/Users/alice/Desktop/peach",
                       "/home/alice/peach", "smb://alice@alice-mbp.local/peach-sync",
                       "peach-two.local", "https://Alice-PC.local/", "https://threading.local/"):
            self.assertTrue(MACHINE_COORDINATE.search(sample), sample)
        for allowed in ("127.0.0.1", "0.0.0.0", "224.0.0.251", "198.18.0.1",
                        "192.0.2.2", "198.51.100.162", "203.0.113.1",
                        "peach.local", "peach-writer.local", "peach-reader.local",
                        "https://<writer>.local", "<mounts.local>/x",
                        ".claude/settings.local.json", "_https._tcp.local.",
                        "copy_database(self.shared, self.local)", "threading.local()", "peach-sync",
                        r"C:\Users\<user>\Desktop\peach", "~/Desktop/<用户目录>/peach",
                        r"C:\Users\RUNNER~1\AppData\Local\Temp", "/home/runner/work",
                        "https://www.pixiv.net/users/93812377",
                        "Chrome/131.0.0.0", "10.0.26200.1234", r"R:\media",
                        "https://github.com/peach-mitao/peach",
                        "Copyright (C) 2026 longmeidao"):
            self.assertIsNone(MACHINE_COORDINATE.search(allowed), allowed)

    def test_the_repository_identity_covers_its_own_github_owner(self):
        """归属名要真的从仓库元数据里解析出来,取不到就等于没剔除。"""
        with (REPO / "pyproject.toml").open("rb") as handle:
            urls = tomllib.load(handle)["project"]["urls"]
        owners = {found.group(1).casefold() for found in
                  (_GITHUB_OWNER.search(url) for url in urls.values()) if found}
        self.assertTrue(owners, "pyproject 的项目 URL 里应当写出仓库的 GitHub 归属")
        self.assertLessEqual(owners, _repository_identity(),
                             "仓库自己的 GitHub 归属没被认出来")

    def test_the_identity_exemption_does_not_hollow_out_the_this_machine_guard(self):
        """剔除只放行「光提到归属名」,同一个名字落进坐标形状照旧拦下。

        用合成的名字断言,这样判据本身不写出任何真实归属。
        """
        owner, other = "example-owner", "peach-two"
        guard = _this_machine_guard({owner, other}, frozenset({owner}))
        for allowed in (f"https://github.com/{owner}/peach",
                        f"io.github.{owner}.peach.tray",
                        f"Copyright (C) 2026 {owner}",
                        f"{owner}-peach", f"{owner}.Peach"):
            self.assertIsNone(guard.search(allowed), allowed)
        for blocked in (f"smb://{other}/peach-sync", f"/Volumes/{other}/media"):
            self.assertIsNotNone(guard.search(blocked), blocked)
        for blocked in (f"/Users/{owner}/Desktop/peach", f"/home/{owner}/peach",
                        rf"C:\Users\{owner}\peach-data",
                        f"smb://{owner}@{owner}-mbp.local/peach-sync"):
            self.assertIsNotNone(MACHINE_COORDINATE.search(blocked), blocked)
        self.assertIsNone(_this_machine_guard({owner}, frozenset({owner})),
                          "名字全被剔掉时不许返回会匹配任何位置的空正则")


class ArchitectureDriftTests(unittest.TestCase):
    """架构文档点名的模块必须真的存在。

    它逼不出「架构变了要重写描述」,但能逼出「模块改名或删掉之后文档还在指它」——
    那种失真最难发现,因为文档读起来一切正常。
    """

    def test_every_module_the_document_names_still_exists(self):
        text = (DOCS / "ARCHITECTURE.md").read_text(encoding="utf-8")
        source = pathlib.Path(peach.__file__).parent
        missing = sorted({
            name for name in re.findall(r"`src/peach/(\w+)\.py`", text)
            if not (source / f"{name}.py").is_file()
        })
        self.assertEqual(missing, [],
                         "架构文档还在指已经不存在的模块,改名或删除时请一起改")


#: `` `docs/HANDOFF.md`「数据安全」「身份、来源与标识采集」 `` 这种写法:一个路径后面跟一个或多个节名。
SECTION_REFERENCE = re.compile(r"`([\w./-]+\.md)`((?:「[^」]+」)+)")


def _headings(path: pathlib.Path) -> list[str]:
    return [line.lstrip("#").strip() for line in path.read_text(encoding="utf-8").splitlines()
            if line.startswith("#")]


class SectionReferenceTests(unittest.TestCase):
    """入口文件、技能与文档指向的「某文件某一节」必须真的有那一节。

    2026-09-13 盘点时四个技能引用的 `docs/HANDOFF.md` 节名已经改掉了,技能读起来一切
    正常,照着去找的人才发现找不到。节名跟着文件走,改标题的人看不见谁在引用它。
    """

    def test_every_referenced_section_exists(self):
        sources = [REPO / "AGENTS.md", REPO / "CLAUDE.md", REPO / "README.md",
                   *sorted((REPO / ".claude" / "skills").glob("*/SKILL.md")),
                   *sorted(DOCS.glob("*.md"))]
        broken = []
        for source in sources:
            for target, names in SECTION_REFERENCE.findall(source.read_text(encoding="utf-8")):
                # 只写文件名的引用在 `docs/` 下找唯一同名文件(技能里引用参考快照就是这么写的)。
                candidates = [REPO / target, source.parent / target,
                              *(sorted(DOCS.rglob(target)) if "/" not in target else [])]
                found = next((path for path in candidates if path.is_file()), None)
                if found is None:
                    broken.append(f"{source.relative_to(REPO).as_posix()} -> {target}(文件不存在)")
                    continue
                headings = _headings(found)
                for name in re.findall(r"「([^」]+)」", names):
                    if not any(heading == name or heading.startswith(name) for heading in headings):
                        broken.append(f"{source.relative_to(REPO).as_posix()} -> {target}「{name}」")
        self.assertEqual(broken, [],
                         "这些引用指向的节已经不存在,改标题时请一起改引用:\n  " + "\n  ".join(broken))


#: ADR 文件名 `NNNN-主题.md` 与首行标题 `# ADR-NNNN:…`。
_ADR_FILE = re.compile(r"^(\d{4})-[^/\\]+\.md$")
_ADR_TITLE = re.compile(r"^# ADR-(\d{4})(?!\d)")


def _adr_numbering_problems(adr_dir: pathlib.Path) -> list[str]:
    """`docs/adr/` 里编号不唯一、文件名与标题编号不符的文件,一条一行。"""
    problems: list[str] = []
    by_number: dict[str, list[str]] = {}
    for path in sorted(adr_dir.glob("*.md")):
        named = _ADR_FILE.match(path.name)
        if named is None:
            problems.append(f"{path.name}:文件名不是 NNNN-主题.md")
            continue
        number = named.group(1)
        by_number.setdefault(number, []).append(path.name)
        first = next(iter(path.read_text(encoding="utf-8").splitlines()), "")
        titled = _ADR_TITLE.match(first)
        if titled is None or titled.group(1) != number:
            problems.append(f"{path.name}:首行标题应以 `# ADR-{number}` 开头,实际是 {first[:40]!r}")
    for number, names in sorted(by_number.items()):
        if len(names) > 1:
            problems.append(f"ADR-{number} 重号:{'、'.join(names)}")
    return problems


class AdrNumberingTests(unittest.TestCase):
    """ADR 编号全树唯一,文件名编号与首行标题编号一致。

    编号由新建 ADR 的人自己取「当前最大号加一」,两个并行工作树各取一次就会撞号,
    而没有任何门槛看编号,ready 与 integrate 两边都放行:2026-09-23 两份 ADR-0044
    就是这样先后合进 master 的。编号是别处引用 ADR 的唯一钥匙,撞号后「见 ADR-0044」
    指向哪一份说不清。后合入的一方 ready 前必须纳入 master 重跑选测,本门槛就在那一步拦住。
    """

    ADR_DIR = DOCS / "adr"

    def test_every_adr_number_is_unique_and_matches_its_title(self):
        problems = _adr_numbering_problems(self.ADR_DIR)
        self.assertEqual(
            problems, [],
            "ADR 编号有冲突:后合入的那份改用当前最大号加一,文件名与首行标题一起改,"
            "再全树搜旧编号的引用:\n  " + "\n  ".join(problems),
        )

    def test_the_guard_catches_the_shape_it_describes(self):
        """门槛自身也要能被证伪:重号、标题错号、文件名不合形状都要报出来。"""
        with tempfile.TemporaryDirectory() as raw:
            sample = pathlib.Path(raw)
            for name, title in (("0044-scraping.md", "# ADR-0044:甲"),
                                ("0044-booru.md", "# ADR-0044:乙"),
                                ("0045-packs.md", "# ADR-0046:丙"),
                                ("0046-bursts.md", "# ADR-00460:丁"),
                                ("0047-fine.md", "# ADR-0047:戊"),
                                ("notes.md", "# 笔记")):
                (sample / name).write_text(title + "\n正文\n", encoding="utf-8")
            problems = _adr_numbering_problems(sample)
        self.assertEqual(problems, [
            "0045-packs.md:首行标题应以 `# ADR-0045` 开头,实际是 '# ADR-0046:丙'",
            "0046-bursts.md:首行标题应以 `# ADR-0046` 开头,实际是 '# ADR-00460:丁'",
            "notes.md:文件名不是 NNNN-主题.md",
            "ADR-0044 重号:0044-booru.md、0044-scraping.md",
        ])

    def test_adding_an_adr_selects_this_guard(self):
        """新建 ADR 的分支按改动选测时必须跑到本门槛,否则撞号照样能过 ready。"""
        scopes, _ = test_runner.scopes_for_changes(["docs/adr/0099-example.md"])
        self.assertTrue(
            any("test_repo_hygiene.py" in test_runner.SCOPES.get(scope, ()) for scope in scopes),
            f"改 docs/adr/ 选到的域 {scopes} 都没有登记 test_repo_hygiene.py",
        )


if __name__ == "__main__":
    unittest.main()