diff --git a/.github/AGENTS.md b/.github/AGENTS.md index a3c9f3b5..c8ec6b3b 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -36,7 +36,7 @@ | 数据与来源 | `check-gongfa.py`;本机研究副本另用`check-research-raw.py`、`check-source-facts.py` | | Wiki | `check-wiki.py`;仍只校验明确给定的本地checkout | | 资料维护 | `sync-doc-toc.py`、`sync-faqi-catalog.py`、`sync-gongfa-catalog.py`;`fetch-research-raw.py`会联网并写入本机研究资料,不属于默认测试 | -| 测试 | `test-gongfa.py`、`test-gongfa-catalog.py`、`test-faqi-catalog.py`、`test-markdown-details.py`、`test-source-archive.py`、`test-research-distribution.py` | +| 测试 | `test-gongfa.py`、`test-gongfa-catalog.py`、`test-faqi-catalog.py`、`test-local-links.py`、`test-markdown-details.py`、`test-source-archive.py`、`test-research-distribution.py` | | 共享实现 | `source_boundaries.py`、`taxonomy.py`、`source_archive.py`、`book_views.py`、`tool_explanations.py`直接平铺;供同级入口与原资料生成器共用,不保留lib包 | | 依赖 | `requirements-gongfa.txt`、`requirements-faqi.txt` | diff --git a/.github/check-local-links.py b/.github/check-local-links.py index 52bdba69..12add256 100644 --- a/.github/check-local-links.py +++ b/.github/check-local-links.py @@ -3,7 +3,9 @@ from __future__ import annotations +import os import re +import subprocess import sys import urllib.parse from pathlib import Path @@ -104,6 +106,45 @@ def should_skip(path: Path) -> bool: return any(rel == prefix or prefix in rel.parents for prefix in SKIP_PREFIXES) +def gitlink_roots() -> tuple[Path, ...]: + """读取已跟踪Gitlink目录,不初始化其工作树。""" + try: + completed = subprocess.run( + ["git", "-C", str(ROOT), "ls-files", "--stage", "-z"], + check=False, + capture_output=True, + timeout=10, + ) + except (OSError, subprocess.TimeoutExpired): + return () + if completed.returncode != 0: + return () + + roots: set[Path] = set() + for entry in completed.stdout.split(b"\0"): + if not entry: + continue + try: + metadata, raw_path = entry.split(b"\t", 1) + except ValueError: + continue + if metadata.split(b" ", 1)[0] == b"160000": + roots.add((ROOT / os.fsdecode(raw_path)).resolve()) + return tuple(sorted(roots)) + + +def is_uninitialized_gitlink_target(destination: Path, roots: tuple[Path, ...]) -> bool: + for root in roots: + if os.path.lexists(root / ".git"): + continue + try: + destination.relative_to(root) + except ValueError: + continue + return True + return False + + def normalize_target(raw: str) -> tuple[str, str]: raw = raw.strip() if not raw or raw.startswith(EXTERNAL_PREFIXES): @@ -122,6 +163,8 @@ def main() -> int: missing_anchors: list[tuple[Path, str, Path, str]] = [] anchor_cache: dict[Path, set[str]] = {} checked_files = 0 + uninitialized_gitlinks = gitlink_roots() + skipped_gitlink_targets = 0 for markdown_file in sorted(ROOT.rglob("*.md")): if should_skip(markdown_file): @@ -145,6 +188,9 @@ def main() -> int: continue if not destination.exists(): + if is_uninitialized_gitlink_target(destination, uninitialized_gitlinks): + skipped_gitlink_targets += 1 + continue missing_links.append((markdown_file.relative_to(ROOT), raw, markdown_file.parent / target)) continue if anchor and destination.suffix.lower() == ".md": @@ -153,6 +199,9 @@ def main() -> int: if anchor not in anchor_cache[destination]: missing_anchors.append((markdown_file.relative_to(ROOT), raw, destination.relative_to(ROOT), anchor)) + if skipped_gitlink_targets: + print(f"SKIPPED_UNINITIALIZED_GITLINK_TARGETS={skipped_gitlink_targets}") + if missing_links or missing_anchors: print("MISSING_LINKS") for source, raw, resolved in missing_links: diff --git a/.github/test-local-links.py b/.github/test-local-links.py new file mode 100644 index 00000000..3aa28cb6 --- /dev/null +++ b/.github/test-local-links.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""验证本地链接检查器对Gitlink和普通缺失目标的区分。""" + +import argparse +import hashlib +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + + +GITLINK_PATH = ".github/extensions/tmux" +GITLINK_SHA = "0123456789abcdef0123456789abcdef01234567" + + +def run_git(repo: Path, *args: str) -> None: + subprocess.run( + ["git", "-C", str(repo), *args], + check=True, + capture_output=True, + text=True, + timeout=10, + ) + + +def build_fixture( + artifacts: Path, + checker: bytes, + boundaries: bytes, + name: str, + target: str, + *, + gitlink: bool = False, + initialized: bool = False, +) -> tuple[Path, Path]: + repo = artifacts / name + script = repo / ".github" / "check-local-links.py" + script.parent.mkdir(parents=True) + script.write_bytes(checker) + script.with_name("source_boundaries.py").write_bytes(boundaries) + (repo / "README.md").write_text("# Fixture\n", encoding="utf-8") + extensions = repo / ".github" / "extensions" + extensions.mkdir(parents=True) + (extensions / "README.md").write_text(f"[target]({target})\n", encoding="utf-8") + + run_git(repo, "init", "--quiet") + if gitlink: + run_git(repo, "update-index", "--add", "--cacheinfo", f"160000,{GITLINK_SHA},{GITLINK_PATH}") + if initialized: + submodule = repo / GITLINK_PATH + submodule.mkdir(parents=True) + (submodule / ".git").write_text("gitdir: ../.git/modules/tmux\n", encoding="utf-8") + return repo, script + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--artifacts", type=Path) + args = parser.parse_args() + checker_path = Path(__file__).with_name("check-local-links.py") + checker = checker_path.read_bytes() + boundaries = checker_path.with_name("source_boundaries.py").read_bytes() + if args.artifacts: + artifacts = args.artifacts.expanduser().resolve() + artifacts.mkdir(parents=True, exist_ok=False) + else: + artifacts = Path(tempfile.mkdtemp(prefix="local-links-test-")) + + cases = ( + ("uninitialized_gitlink", "tmux/README", True, False, 0), + ("ordinary_missing_target", "missing.md", False, False, 1), + ("initialized_gitlink_missing_target", "tmux/README", True, True, 1), + ) + records = [] + + class LocalLinksTest(unittest.TestCase): + def test_gitlink_and_missing_target_boundaries(self): + for name, target, gitlink, initialized, expected_exit in cases: + with self.subTest(case=name): + repo, script = build_fixture( + artifacts, + checker, + boundaries, + name, + target, + gitlink=gitlink, + initialized=initialized, + ) + command = [sys.executable, str(script)] + completed = subprocess.run( + command, + cwd=repo, + capture_output=True, + text=True, + timeout=10, + ) + (artifacts / f"{name}.stdout.log").write_text(completed.stdout, encoding="utf-8") + (artifacts / f"{name}.stderr.log").write_text(completed.stderr, encoding="utf-8") + records.append({ + "case": name, + "command": command, + "expected_exit_code": expected_exit, + "actual_exit_code": completed.returncode, + }) + self.assertEqual(completed.returncode, expected_exit, completed.stdout + completed.stderr) + if name == "uninitialized_gitlink": + self.assertIn("SKIPPED_UNINITIALIZED_GITLINK_TARGETS=1", completed.stdout) + self.assertNotIn("MISSING_LINKS", completed.stdout) + else: + self.assertIn("MISSING_LINKS", completed.stdout) + self.assertIn("TOTAL_LINKS=1", completed.stdout) + + suite = unittest.defaultTestLoader.loadTestsFromTestCase(LocalLinksTest) + result = unittest.TextTestRunner(verbosity=2).run(suite) + report = { + "success": result.wasSuccessful(), + "cases": records, + "checker_sha256": hashlib.sha256(checker).hexdigest(), + "source_boundaries_sha256": hashlib.sha256(boundaries).hexdigest(), + "test_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), + } + (artifacts / "result.json").write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(json.dumps({"success": report["success"], "cases": len(records), "artifacts": str(artifacts)}, ensure_ascii=False)) + return 0 if result.wasSuccessful() else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/AGENTS.md b/AGENTS.md index 62daf691..280c23ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,7 +61,7 @@ Lambda演算已按用户要求加入功法,品级天级上品(现行`tian-ad | 范围 | 命令 | |---|---| -| Markdown与导航 | `make lint`、`make check-links`、`make check-details`、`make test-details` | +| Markdown与导航 | `make lint`、`make check-links`、`make test-local-links`、`make check-details`、`make test-details` | | 结构与索引 | `make check-doc-structure`、`make check-directory-docs`、`make check-metadata` | | 功法协议与视图 | `make check-gongfa`、`make test-gongfa`、`make sync-gongfa-catalog`、`make check-gongfa-catalog`、`make test-gongfa-catalog` | | 法器与资料档案 | `make sync-faqi-catalog`、`make check-faqi-catalog`、`make test-faqi-catalog`、`make test-source-archive` | diff --git a/Makefile b/Makefile index 7f211066..1d1465bc 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ # Vibe Coding工具箱:资源分三域,测试与校验维护入口统一在.github。 -.PHONY: help lint check-links check-details test-details check-doc-structure check-directory-docs check-metadata check-gongfa test-gongfa sync-gongfa-catalog check-gongfa-catalog test-gongfa-catalog sync-faqi-catalog check-faqi-catalog test-faqi-catalog test-source-archive test-research-distribution check-ai-citation check-research-raw check-source-facts test-local-research check-wiki fetch-research-raw sync-doc-toc build test clean clean-deps +.PHONY: help lint check-links test-local-links check-details test-details check-doc-structure check-directory-docs check-metadata check-gongfa test-gongfa sync-gongfa-catalog check-gongfa-catalog test-gongfa-catalog sync-faqi-catalog check-faqi-catalog test-faqi-catalog test-source-archive test-research-distribution check-ai-citation check-research-raw check-source-facts test-local-research check-wiki fetch-research-raw sync-doc-toc build test clean clean-deps MARKDOWNLINT = npx --yes markdownlint-cli@0.48.0 MAINT = .github @@ -9,7 +9,7 @@ SOURCE_IGNORES = --ignore .history --ignore '$(EXT)/tmux/**' --ignore '$(EXT)/oh help: @echo "Vibe Coding工具箱" - @echo "lint / check-links / check-details / test-details:Markdown格式、内链、折叠与回归" + @echo "lint / check-links / test-local-links / check-details / test-details:Markdown格式、内链、折叠与回归" @echo "check-doc-structure / check-directory-docs / check-metadata:阅读结构、目录职责与机器索引" @echo "check-gongfa / test-gongfa:功法来源与既有评级协议" @echo "sync-gongfa-catalog / check-gongfa-catalog / test-gongfa-catalog:功法四阶Markdown视图" @@ -30,6 +30,9 @@ lint: check-links: @python3 $(MAINT)/check-local-links.py +test-local-links: + @python3 $(MAINT)/test-local-links.py + check-details: @python3 $(MAINT)/check-markdown-details.py @@ -99,7 +102,7 @@ sync-doc-toc: build: @echo "无需构建;本仓是文档、既有Skills和小工具的资源集合。" -test: lint check-links check-details test-details check-doc-structure check-directory-docs check-metadata check-gongfa test-gongfa check-gongfa-catalog test-gongfa-catalog check-faqi-catalog test-faqi-catalog test-source-archive test-research-distribution check-ai-citation +test: lint check-links test-local-links check-details test-details check-doc-structure check-directory-docs check-metadata check-gongfa test-gongfa check-gongfa-catalog test-gongfa-catalog check-faqi-catalog test-faqi-catalog test-source-archive test-research-distribution check-ai-citation @echo "本地默认质量检查完成;不含本机研究档案专用检查,也不代表工具效果、运行可用性或许可批准。" clean: clean-deps