"""仓库与顶层目录的整洁门槛。 检查顶层布局、工作树登记、私有产物和公开文档中的个人信息。 """ 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("/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/<名字>/…`。名字段要以字母开头, #: ``、`<用户目录>` 这类占位由此放行;前面不许紧贴单词字符,`pixiv.net/users/…` 这种 #: URL 路径不算家目录。CI 靶机的 `runner`/`runneradmin`(含 8.3 短名 `RUNNER~1`)是文档里 #: 会正当提到的名字,放行。 _HOME_DIRECTORY = (r"(?.local`。只放行三个有文档意义的占位:默认名 `peach` 与讲双机布局用的 #: `peach-writer`/`peach-reader`。`self.local`/`this.local` 是属性名与后缀同形,不算主机名; #: `threading.local()` 是标准库调用;同名主机地址仍按机器坐标检查。 #: `` 是占位;`settings.local.json`、`_tcp.local.` 后面还有段,都不是主机名。 _MDNS_HOST = (r"(?/` 与 #: `git@github.com:/` 两种写法都收。 _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, [], "把它搬进 /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\\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://.local", "/x", ".claude/settings.local.json", "_https._tcp.local.", "copy_database(self.shared, self.local)", "threading.local()", "peach-sync", r"C:\Users\\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()