diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 00000000..5ba1bd65 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,19 @@ +[run] +branch = True +source = + scripts + plugins/hotspot-to-rq/skills/research-direction-debate/scripts +omit = + tests/* + plugins/hotspot-to-rq/skills/research-direction-debate/vendor/* + +[report] +show_missing = True +skip_covered = False +precision = 1 + +[html] +directory = htmlcov + +[xml] +output = coverage.xml diff --git a/.github/workflows/plugin-ci.yml b/.github/workflows/plugin-ci.yml index 8bc59015..6594a297 100644 --- a/.github/workflows/plugin-ci.yml +++ b/.github/workflows/plugin-ci.yml @@ -43,14 +43,35 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install validator dependencies - run: python -m pip install --disable-pip-version-check PyYAML==6.0.2 + run: >- + python -m pip install --disable-pip-version-check + PyYAML==6.0.2 coverage==7.15.2 - name: Run plugin checks env: CODEX_PLUGIN_VALIDATOR: ${{ github.workspace }}/.ci/openai-codex/codex-rs/skills/src/assets/samples/plugin-creator/scripts/validate_plugin.py CODEX_SKILL_VALIDATOR: ${{ github.workspace }}/.ci/openai-codex/codex-rs/skills/src/assets/samples/skill-creator/scripts/quick_validate.py + PYTHON_COVERAGE: "1" run: bash scripts/test_plugin.sh + - name: Build coverage reports + if: always() + run: | + python -m coverage report --show-missing + python -m coverage xml + python -m coverage html + + - name: Upload coverage reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: python-${{ matrix.python-version }}-coverage + path: | + coverage.xml + htmlcov/ + if-no-files-found: error + retention-days: 14 + claude: name: Validate Claude Code plugin runs-on: ubuntu-latest diff --git a/.github/workflows/sync-paper-notes.yml b/.github/workflows/sync-paper-notes.yml index a968ef1b..20739109 100644 --- a/.github/workflows/sync-paper-notes.yml +++ b/.github/workflows/sync-paper-notes.yml @@ -6,7 +6,9 @@ on: workflow_dispatch: permissions: + actions: write contents: write + pull-requests: write concurrency: group: sync-paper-notes-main @@ -29,12 +31,17 @@ jobs: run: bash scripts/sync_paper_notes.sh - name: Commit updated corpus when changed + id: prepare + env: + SYNC_BRANCH: automation/paper-notes-sync run: | if [[ -z "$(git status --porcelain -- data/Paper-Notes)" ]]; then echo "Paper-Notes corpus is already current." + echo "changed=false" >> "$GITHUB_OUTPUT" exit 0 fi + git switch -C "$SYNC_BRANCH" git add -A data/Paper-Notes # Preserve upstream Markdown byte-for-byte. Its existing whitespace is # not ours to normalize, so only lint the provenance file we generate. @@ -43,4 +50,56 @@ jobs: git config user.email "41898282+github-actions[bot]@users.noreply.github.com" upstream_commit="$(sed -n 's/^upstream_commit: //p' data/Paper-Notes/UPSTREAM.md | head -n 1)" git commit --quiet -m "chore(data): sync Paper-Notes (${upstream_commit:0:12})" - git push origin HEAD:main + git fetch origin "+refs/heads/$SYNC_BRANCH:refs/remotes/origin/$SYNC_BRANCH" || true + git push --force-with-lease origin "HEAD:refs/heads/$SYNC_BRANCH" + echo "branch=$SYNC_BRANCH" >> "$GITHUB_OUTPUT" + echo "changed=true" >> "$GITHUB_OUTPUT" + echo "upstream_commit=$upstream_commit" >> "$GITHUB_OUTPUT" + + - name: Create or update synchronization pull request + if: steps.prepare.outputs.changed == 'true' + id: pull_request + env: + GH_TOKEN: ${{ github.token }} + SYNC_BRANCH: ${{ steps.prepare.outputs.branch }} + UPSTREAM_COMMIT: ${{ steps.prepare.outputs.upstream_commit }} + run: | + pr_url="$( + gh pr list \ + --repo "$GITHUB_REPOSITORY" \ + --base main \ + --head "$SYNC_BRANCH" \ + --state open \ + --json url \ + --jq '.[0].url' + )" + if [[ -z "$pr_url" ]]; then + pr_url="$( + gh pr create \ + --repo "$GITHUB_REPOSITORY" \ + --base main \ + --head "$SYNC_BRANCH" \ + --title "chore(data): sync Paper-Notes (${UPSTREAM_COMMIT:0:12})" \ + --body "Automated weekly Paper-Notes corpus synchronization. Upstream commit: $UPSTREAM_COMMIT" + )" + else + gh pr edit "$pr_url" \ + --repo "$GITHUB_REPOSITORY" \ + --title "chore(data): sync Paper-Notes (${UPSTREAM_COMMIT:0:12})" \ + --body "Automated weekly Paper-Notes corpus synchronization. Upstream commit: $UPSTREAM_COMMIT" + fi + echo "url=$pr_url" >> "$GITHUB_OUTPUT" + + - name: Dispatch Plugin CI for synchronization branch + if: steps.prepare.outputs.changed == 'true' + env: + GH_TOKEN: ${{ github.token }} + SYNC_BRANCH: ${{ steps.prepare.outputs.branch }} + run: gh workflow run plugin-ci.yml --repo "$GITHUB_REPOSITORY" --ref "$SYNC_BRANCH" + + - name: Enable auto-merge after required checks + if: steps.prepare.outputs.changed == 'true' + env: + GH_TOKEN: ${{ github.token }} + PR_URL: ${{ steps.pull_request.outputs.url }} + run: gh pr merge "$PR_URL" --repo "$GITHUB_REPOSITORY" --auto --squash --delete-branch diff --git a/.gitignore b/.gitignore index 3b4b72a5..a5b0700e 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,7 @@ reports/ # Python and operating-system artifacts __pycache__/ *.py[cod] +.coverage +coverage.xml +htmlcov/ .DS_Store diff --git a/README.md b/README.md index 1af8452f..08609c0f 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,21 @@ Mainline Workflow Controller 是只读控制面,不是第二个主代理:它 还会对 Claude 侧 manifest 与 marketplace 运行 `claude plugin validate --strict`(Codex manifest 由 Codex 校验器与 JSON 语法检查覆盖)。 +CI 还会使用固定版本的 `coverage.py` 生成行覆盖率和分支覆盖率报告。需要在 +本地复现该模式时,先安装 `coverage==7.15.2`,再运行: + +```bash +PYTHON_COVERAGE=1 ./scripts/test_plugin.sh +python3 -m coverage report --show-missing +python3 -m coverage html +``` + +覆盖率报告用于发现缺失路径,当前不设置百分比门槛;任何测试、Codex +validator 或 Claude strict validator 失败仍会令 CI 失败。`main` 分支的 +仓库 ruleset 应将 `Validate plugin (Python 3.11)`、`Validate plugin +(Python 3.13)` 和 `Validate Claude Code plugin` 配置为 required status +checks,确保失败的 PR 不能合并。 + 端到端 smoke test: 1. 按“本地开发安装”安装或刷新 plugin;用 `codex plugin list --json` 确认 @@ -231,4 +246,7 @@ python3 scripts/build_trend_report.py GitHub Actions 的 **Sync Paper-Notes Corpus** 工作流会在每周一 `07:17 UTC` 自动运行。也可以在仓库的 **Actions** 页面选择该工作流、点击 **Run workflow** -手动同步;只有上游 `docs/` 或许可证版本改变时,机器人才会提交到 `main`。 +手动同步;只有上游 `docs/` 或许可证版本改变时,机器人会更新 +`automation/paper-notes-sync` 分支并创建 PR。该 PR 会显式触发 **Plugin CI**, +三项 required status checks 通过后自动 squash 合并,因此同步任务不需要绕过 +`main` 的 ruleset。 diff --git a/scripts/analyze_paper_notes.py b/scripts/analyze_paper_notes.py index 1880440d..0d5f91e5 100644 --- a/scripts/analyze_paper_notes.py +++ b/scripts/analyze_paper_notes.py @@ -1,70 +1,142 @@ #!/usr/bin/env python3 """Build a lightweight trend report from Paper-Notes Markdown files.""" + +from __future__ import annotations + +import argparse from collections import Counter, defaultdict +import json from pathlib import Path import re -import json -import sys +from typing import Iterable + + +DEFAULT_ROOT = Path("data/Paper-Notes/docs") +DEFAULT_OUT = Path("reports/paper-notes-trends.json") +VALID_CONFERENCE = re.compile( + r"(?:ACL|AAAI|CVPR|ECCV|ICCV|ICLR|ICML|NeurIPS)\d{4}" +) +TOPIC_PATTERN = re.compile(r"([^:·]+?)[×x](\d+)") -ROOT = Path(sys.argv[1] if len(sys.argv) > 1 else "data/Paper-Notes/docs") -OUT = Path(sys.argv[2] if len(sys.argv) > 2 else "reports/paper-notes-trends.json") -def frontmatter(text): +def frontmatter(text: str) -> dict[str, str]: + """Parse the intentionally small scalar subset used by Paper-Notes.""" if not text.startswith("---"): return {} - block = text.split("---", 2)[1] - data = {} - for line in block.splitlines(): - m = re.match(r"^([A-Za-z_]+):\s*(.*)$", line) - if m: - data[m.group(1)] = m.group(2).strip().strip('"') + parts = text.split("---", 2) + if len(parts) < 3: + return {} + data: dict[str, str] = {} + for line in parts[1].splitlines(): + match = re.match(r"^([A-Za-z_]+):\s*(.*)$", line) + if match: + data[match.group(1)] = match.group(2).strip().strip('"') return data -files = list(ROOT.glob("**/*.md")) -conference = Counter() -area = Counter() -keywords = Counter() -examples = defaultdict(list) - -for path in files: - rel = path.relative_to(ROOT) - if len(rel.parts) < 2 or path.name == "search.md": - continue - conf, field = rel.parts[0], rel.parts[1] - if path.name == "index.md": - text = path.read_text(errors="ignore") - for line in text.splitlines(): - if "高频主题" in line: - for word, count in re.findall(r"([^::·]+?)[×x](\d+)", line): - keywords[word.strip()] += int(count) - continue - if not re.fullmatch(r"(?:ACL|AAAI|CVPR|ECCV|ICCV|ICLR|ICML|NeurIPS)\d{4}", conf): - continue - conference[conf] += 1 - area[field] += 1 - text = path.read_text(errors="ignore") - meta = frontmatter(text) - title = meta.get("title", path.stem).replace("\\n", " ") - # The generated index exposes a compact, useful signal in this line. - for line in text.splitlines(): - if "高频主题" in line: - for word, count in re.findall(r"([^::·]+?)[×x](\d+)", line): - keywords[word.strip()] += int(count) - if len(examples[field]) < 3: - examples[field].append(title) - -report = { - "source": str(ROOT), - "markdown_files": len(files), - "paper_files": sum(conference.values()), - "conferences": conference.most_common(), - "areas": area.most_common(), - "high_frequency_topics": keywords.most_common(30), - "examples": dict(examples), -} -OUT.parent.mkdir(parents=True, exist_ok=True) -OUT.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n") -print(f"写入 {OUT}: {report['paper_files']} 篇论文,{len(conference)} 个会议,{len(area)} 个领域") -print("会议:", report["conferences"]) -print("领域 Top 15:", report["areas"][:15]) -print("主题 Top 15:", report["high_frequency_topics"][:15]) + +def _stable_counts(counter: Counter[str], limit: int | None = None) -> list[tuple[str, int]]: + rows = sorted(counter.items(), key=lambda item: (-item[1], item[0])) + return rows if limit is None else rows[:limit] + + +def _record_topics(lines: Iterable[str], keywords: Counter[str]) -> None: + for line in lines: + if "高频主题" not in line: + continue + for word, count in TOPIC_PATTERN.findall(line): + normalized = word.strip() + if normalized: + keywords[normalized] += int(count) + + +def analyze_corpus(root: Path) -> dict[str, object]: + """Analyze one corpus root without reading or writing outside that root.""" + files = sorted( + root.glob("**/*.md"), + key=lambda path: path.relative_to(root).as_posix(), + ) + conference: Counter[str] = Counter() + area: Counter[str] = Counter() + keywords: Counter[str] = Counter() + examples: defaultdict[str, list[str]] = defaultdict(list) + + for path in files: + if path.is_symlink(): + continue + rel = path.relative_to(root) + if len(rel.parts) < 2 or path.name == "search.md": + continue + conf, field = rel.parts[0], rel.parts[1] + if not VALID_CONFERENCE.fullmatch(conf): + continue + + text = path.read_text(encoding="utf-8", errors="ignore") + if path.name == "index.md": + _record_topics(text.splitlines(), keywords) + continue + + conference[conf] += 1 + area[field] += 1 + meta = frontmatter(text) + title = meta.get("title", path.stem).replace("\\n", " ") + _record_topics(text.splitlines(), keywords) + if len(examples[field]) < 3: + examples[field].append(title) + + return { + "source": str(root), + "markdown_files": len(files), + "paper_files": sum(conference.values()), + "conferences": _stable_counts(conference), + "areas": _stable_counts(area), + "high_frequency_topics": _stable_counts(keywords, 30), + "examples": { + field: examples[field] + for field in sorted(examples) + }, + } + + +def write_report(report: dict[str, object], output: Path) -> None: + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text( + json.dumps(report, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "root", + nargs="?", + type=Path, + default=DEFAULT_ROOT, + help=f"Paper-Notes docs root (default: {DEFAULT_ROOT})", + ) + parser.add_argument( + "output", + nargs="?", + type=Path, + default=DEFAULT_OUT, + help=f"JSON report path (default: {DEFAULT_OUT})", + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + report = analyze_corpus(args.root) + write_report(report, args.output) + print( + f"写入 {args.output}: {report['paper_files']} 篇论文," + f"{len(report['conferences'])} 个会议,{len(report['areas'])} 个领域" + ) + print("会议:", report["conferences"]) + print("领域 Top 15:", report["areas"][:15]) + print("主题 Top 15:", report["high_frequency_topics"][:15]) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/build_trend_report.py b/scripts/build_trend_report.py index bcb1a1da..37c79d61 100644 --- a/scripts/build_trend_report.py +++ b/scripts/build_trend_report.py @@ -1,45 +1,147 @@ #!/usr/bin/env python3 +"""Build the human-readable Paper-Notes trend report.""" + +from __future__ import annotations + +import argparse from collections import Counter, defaultdict from pathlib import Path import re +from typing import Mapping + + +DEFAULT_ROOT = Path("data/Paper-Notes/docs") +DEFAULT_OUT = Path("reports/trend-report.md") +VALID_CONFERENCE = re.compile( + r"(?:ACL|AAAI|CVPR|ECCV|ICCV|ICLR|ICML|NeurIPS)\d{4}" +) + + +def collect_counts(root: Path) -> dict[str, Counter[str]]: + """Count paper Markdown files by conference and area.""" + counts: defaultdict[str, Counter[str]] = defaultdict(Counter) + paths = sorted( + root.glob("**/*.md"), + key=lambda path: path.relative_to(root).as_posix(), + ) + for path in paths: + if path.is_symlink(): + continue + rel = path.relative_to(root) + if len(rel.parts) < 3 or path.name in {"index.md", "search.md"}: + continue + conf, field = rel.parts[:2] + if VALID_CONFERENCE.fullmatch(conf): + counts[conf][field] += 1 + return dict(counts) + + +def _total(counts: Mapping[str, Counter[str]], conference: str) -> int: + return sum(counts.get(conference, Counter()).values()) + + +def render_report(counts: Mapping[str, Counter[str]]) -> str: + """Render deterministic Markdown from precomputed counts.""" + comparisons: list[tuple[str, str, str, list[tuple[int, str, int, int]]]] = [] + for conf in ("CVPR", "ACL", "ICML"): + old, new = f"{conf}2025", f"{conf}2026" + old_counts = counts.get(old, Counter()) + new_counts = counts.get(new, Counter()) + fields = set(old_counts) | set(new_counts) + rows = [] + for field in fields: + before, after = old_counts[field], new_counts[field] + if before + after >= 8: + rows.append((after - before, field, before, after)) + rows.sort(key=lambda row: (-row[0], row[1])) + comparisons.append((conf, old, new, rows)) + + lines = [ + "# PaperNotes 论文热点趋势报告", + "", + "> 数据源:zhaoyang97/Paper-Notes;生成时间:2026-07-22。", + "", + "## 数据概况", + "", + ( + f"当前共统计 **{sum(_total(counts, conf) for conf in counts)} 篇论文**," + f"覆盖 **{len(counts)} 个会议**。" + ), + "", + "## 2025 → 2026 领域变化", + "", + ] + for conf, old, new, rows in comparisons: + lines.extend( + [ + f"### {conf}({_total(counts, old)} → {_total(counts, new)})", + "", + "| 变化 | 领域 | 2025 | 2026 |", + "|---:|---|---:|---:|", + ] + ) + for delta, field, before, after in rows[:10]: + lines.append(f"| {delta:+d} | `{field}` | {before} | {after} |") + lines.append("") + + lines.extend( + [ + "## 初步判断", + "", + "### 高热但竞争激烈", + "", + "- 图像生成、3D 视觉、多模态 VLM:论文基数最大,适合做综述、基准、效率和可靠性方向。", + "- 强化学习、模型压缩:数量大且持续出现,单纯提出小改进的空间较小。", + "", + "### 值得重点寻找空白", + "", + "- LLM Agent 的可靠性、成本、长程任务和安全评测。", + "- 多模态模型的推理效率、数据质量和真实场景泛化。", + "- 具身智能与 Agent 的交叉:规划、工具使用、记忆和多智能体协作。", + "- AI 安全与实际部署结合,而不是只做静态攻击展示。", + "", + "## 下一步选题筛选规则", + "", + "1. 优先选择增长明显但总量尚未饱和的领域。", + "2. 检查论文是否有公开代码、数据集和可复现实验。", + "3. 优先寻找跨会议重复出现、但评测标准不统一的问题。", + "4. 每个候选主题至少阅读 10 篇代表性论文后再决定是否投入。", + "", + ] + ) + return "\n".join(lines) + + +def write_report(report: str, output: Path) -> None: + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(report, encoding="utf-8") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "root", + nargs="?", + type=Path, + default=DEFAULT_ROOT, + help=f"Paper-Notes docs root (default: {DEFAULT_ROOT})", + ) + parser.add_argument( + "output", + nargs="?", + type=Path, + default=DEFAULT_OUT, + help=f"Markdown report path (default: {DEFAULT_OUT})", + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + write_report(render_report(collect_counts(args.root)), args.output) + print(f"写入 {args.output}") + return 0 + -ROOT = Path("data/Paper-Notes/docs") -OUT = Path("reports/trend-report.md") -valid = re.compile(r"(?:ACL|AAAI|CVPR|ECCV|ICCV|ICLR|ICML|NeurIPS)\d{4}") - -counts = defaultdict(Counter) -for p in ROOT.glob("**/*.md"): - rel = p.relative_to(ROOT) - if len(rel.parts) < 3 or p.name == "index.md": - continue - conf, field = rel.parts[:2] - if valid.fullmatch(conf): - counts[conf][field] += 1 - -def total(conf): - return sum(counts[conf].values()) - -comparisons = [] -for conf in ("CVPR", "ACL", "ICML"): - old, new = f"{conf}2025", f"{conf}2026" - fields = set(counts[old]) | set(counts[new]) - rows = [] - for field in fields: - a, b = counts[old][field], counts[new][field] - if a + b >= 8: - rows.append((b - a, field, a, b)) - comparisons.append((conf, old, new, sorted(rows, reverse=True))) - -lines = ["# PaperNotes 论文热点趋势报告", "", "> 数据源:zhaoyang97/Paper-Notes;生成时间:2026-07-22。", ""] -lines += ["## 数据概况", "", f"当前共统计 **{sum(total(c) for c in counts)} 篇论文**,覆盖 **{len(counts)} 个会议**。", ""] -lines += ["## 2025 → 2026 领域变化", ""] -for conf, old, new, rows in comparisons: - lines += [f"### {conf}({total(old)} → {total(new)})", "", "| 变化 | 领域 | 2025 | 2026 |", "|---:|---|---:|---:|"] - for delta, field, a, b in rows[:10]: - lines.append(f"| {delta:+d} | `{field}` | {a} | {b} |") - lines.append("") - -lines += ["## 初步判断", "", "### 高热但竞争激烈", "", "- 图像生成、3D 视觉、多模态 VLM:论文基数最大,适合做综述、基准、效率和可靠性方向。", "- 强化学习、模型压缩:数量大且持续出现,单纯提出小改进的空间较小。", "", "### 值得重点寻找空白", "", "- LLM Agent 的可靠性、成本、长程任务和安全评测。", "- 多模态模型的推理效率、数据质量和真实场景泛化。", "- 具身智能与 Agent 的交叉:规划、工具使用、记忆和多智能体协作。", "- AI 安全与实际部署结合,而不是只做静态攻击展示。", "", "## 下一步选题筛选规则", "", "1. 优先选择增长明显但总量尚未饱和的领域。", "2. 检查论文是否有公开代码、数据集和可复现实验。", "3. 优先寻找跨会议重复出现、但评测标准不统一的问题。", "4. 每个候选主题至少阅读 10 篇代表性论文后再决定是否投入。", ""] -OUT.parent.mkdir(exist_ok=True) -OUT.write_text("\n".join(lines)) -print(f"写入 {OUT}") +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test_plugin.sh b/scripts/test_plugin.sh index 87a6caff..beeed868 100755 --- a/scripts/test_plugin.sh +++ b/scripts/test_plugin.sh @@ -7,7 +7,13 @@ plugin_root="${repo_root}/plugins/hotspot-to-rq" skill_root="${plugin_root}/skills/research-direction-debate" python_bin="${PYTHON_BIN:-python3}" -"${python_bin}" -B -m unittest discover -s "${repo_root}/tests" -v +if [[ "${PYTHON_COVERAGE:-0}" = "1" ]]; then + "${python_bin}" -B -m coverage erase + "${python_bin}" -B -m coverage run --branch \ + -m unittest discover -s "${repo_root}/tests" -v +else + "${python_bin}" -B -m unittest discover -s "${repo_root}/tests" -v +fi plugin_validator="${CODEX_PLUGIN_VALIDATOR:-${codex_base}/skills/.system/plugin-creator/scripts/validate_plugin.py}" skill_validator="${CODEX_SKILL_VALIDATOR:-${codex_base}/skills/.system/skill-creator/scripts/quick_validate.py}" if [[ -n "${CODEX_PLUGIN_VALIDATOR:-}" || -n "${CODEX_SKILL_VALIDATOR:-}" ]]; then @@ -35,6 +41,7 @@ else fi "${python_bin}" -B "${skill_root}/scripts/validate_controller_decision.py" --help >/dev/null "${python_bin}" -B "${skill_root}/scripts/validate_session.py" --help >/dev/null +"${python_bin}" -B "${skill_root}/scripts/build_control_input.py" --help >/dev/null "${python_bin}" -B "${skill_root}/scripts/build_context_capsule.py" --help >/dev/null "${python_bin}" -B "${skill_root}/scripts/build_codex_dispatch.py" --help >/dev/null "${python_bin}" -B "${skill_root}/scripts/validate_codex_dispatch_batch.py" --help >/dev/null diff --git a/tests/fixture_builders.py b/tests/fixture_builders.py new file mode 100644 index 00000000..75b881b8 --- /dev/null +++ b/tests/fixture_builders.py @@ -0,0 +1,257 @@ +"""Small deterministic fixtures shared by repository workflow tests.""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +import subprocess + + +def write_markdown(root: Path, relative: str, text: str = "") -> Path: + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + +def build_mini_paper_notes(root: Path) -> Path: + """Create a corpus with valid, ignored, Unicode, and threshold cases.""" + docs = root / "docs" + write_markdown( + docs, + "CVPR2025/vision/index.md", + "# Vision\n\n高频主题:生成模型×2 · 可靠性 x1\n", + ) + write_markdown( + docs, + "CVPR2026/vision/index.md", + "# Vision\n\n高频主题:生成模型×3 · 具身智能 x2\n", + ) + for year, count in ((2025, 4), (2026, 6)): + for index in range(count): + title = ( + '---\ntitle: "统一视觉标题\\n续行"\n---\n' + if index == 0 + else f"正文 {year}-{index}\n" + ) + if year == 2026 and index == 1: + title += "高频主题:可靠性×2\n" + write_markdown( + docs, + f"CVPR{year}/vision/paper-{index:02d}.md", + title, + ) + + # Equal-count areas exercise deterministic name ordering. + write_markdown(docs, "ACL2026/zeta/paper.md", "zeta\n") + write_markdown(docs, "ACL2026/alpha/paper.md", "alpha\n") + write_markdown(docs, "ACL2026/alpha/search.md", "ignored\n") + write_markdown( + docs, + "NOTACONF2026/invalid/index.md", + "高频主题:不应出现×99\n", + ) + write_markdown(docs, "NOTACONF2026/invalid/paper.md", "ignored\n") + write_markdown(docs, "root-note.md", "ignored\n") + return docs + + +def initialize_upstream_repository(root: Path) -> Path: + upstream = root / "upstream" + upstream.mkdir() + subprocess.run( + ["git", "init", "--initial-branch=main"], + cwd=upstream, + check=True, + capture_output=True, + text=True, + ) + subprocess.run( + ["git", "config", "user.name", "Test User"], + cwd=upstream, + check=True, + ) + subprocess.run( + ["git", "config", "user.email", "test@example.com"], + cwd=upstream, + check=True, + ) + build_mini_paper_notes(upstream) + (upstream / "LICENSE").write_text("CC BY-NC-SA 4.0\n", encoding="utf-8") + subprocess.run( + ["git", "add", "docs", "LICENSE"], + cwd=upstream, + check=True, + ) + subprocess.run( + ["git", "commit", "-m", "fixture corpus"], + cwd=upstream, + check=True, + capture_output=True, + text=True, + ) + return upstream + + +def write_json(path: Path, value: object, *, mode: int | None = None) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(value, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + if mode is not None: + path.chmod(mode) + return path + + +def build_schema14_session( + root: Path, + *, + transport_profile: str = "CODEX", + packet_id: str = "C01-R1-MENTOR", +) -> Path: + """Create the minimal shared schema-1.4 session used by transport tests.""" + session = root / "session-1" + (session / "control-inputs" / "dispatch-drafts").mkdir(parents=True) + (session / "project-evidence-pack.md").write_text( + "Observed signal A\n", + encoding="utf-8", + ) + write_json( + session / "session-state.json", + { + "schema_version": "1.4", + "transport_profile": transport_profile, + "session_id": "session-1", + "project_root": str(root.resolve()), + "project_snapshot": "snapshot-1", + "search_budget": { + "profile": "standard", + "large_downloads": [], + "approved_extensions": [], + }, + "accepted_work_products": [], + }, + ) + (session / "control-input.json").write_text("{}\n", encoding="utf-8") + write_dispatch_draft(session, packet_id=packet_id) + return session + + +def write_dispatch_draft( + session: Path, + *, + packet_id: str, + candidate_id: str | None = "C01", + phase: str = "DEBATE", + role: str = "Socratic Mentor", + round_number: int | None = 1, + role_instructions: str = "Ask one bounded Socratic question.", + inline_payload: object | None = None, + search_budget: dict[str, object] | None = None, +) -> Path: + draft = { + "envelope": { + "schema_version": "1.0", + "session_id": "session-1", + "project_root": str(session.parent.resolve()), + "project_snapshot": "snapshot-1", + "phase": phase, + "role": role, + "candidate_id": candidate_id, + "round": round_number, + "packet_id": packet_id, + }, + "role_instructions": role_instructions, + "inline_payload": ( + {"candidate": candidate_id} + if inline_payload is None + else inline_payload + ), + "search_budget": search_budget, + } + return write_json( + session + / "control-inputs" + / "dispatch-drafts" + / f"{packet_id}.json", + draft, + ) + + +def research_context_fingerprint(envelope: dict[str, object]) -> str: + identity = { + key: envelope.get(key) + for key in ( + "session_id", + "project_root", + "project_snapshot", + "phase", + "role", + "candidate_id", + "round", + "packet_id", + ) + } + compact = json.dumps( + identity, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ) + return hashlib.sha256(compact.encode("utf-8")).hexdigest() + + +def write_claude_dispatch( + session: Path, + state: dict[str, object], + *, + packet_id: str = "MAP-CLAUDE", + phase: str = "DIRECTION_MAPPING", + role: str = "Macro Direction Mapper", + candidate_id: str | None = None, + round_number: int | None = None, +) -> dict[str, object]: + """Persist one valid immutable Claude transport and return its dispatch.""" + allowed_artifacts = [str((session / "project-evidence-pack.md").resolve())] + envelope: dict[str, object] = { + "schema_version": "1.0", + "session_id": state["session_id"], + "project_root": state["project_root"], + "project_snapshot": state["project_snapshot"], + "phase": phase, + "role": role, + "candidate_id": candidate_id, + "round": round_number, + "packet_id": packet_id, + "context_fingerprint": "", + "allowed_artifacts": allowed_artifacts, + } + envelope["context_fingerprint"] = research_context_fingerprint(envelope) + transport_relative = f"control-inputs/dispatches/{packet_id}.json" + transport = { + "schema_version": "claude-dispatch-input-1", + "envelope": envelope, + "role_instructions": "Use only the bounded evidence.", + "inline_payload": {}, + "allowed_artifact_paths": allowed_artifacts, + "search_budget": None, + } + transport_path = write_json( + session / transport_relative, + transport, + mode=0o400, + ) + return { + "packet_id": packet_id, + "phase": phase, + "role": role, + "candidate_id": candidate_id, + "round": round_number, + "depends_on_packet_ids": [], + "transport_path": transport_relative, + "transport_sha256": hashlib.sha256( + transport_path.read_bytes() + ).hexdigest(), + } diff --git a/tests/test_ci_workflows.py b/tests/test_ci_workflows.py index cb505916..d0b0f211 100644 --- a/tests/test_ci_workflows.py +++ b/tests/test_ci_workflows.py @@ -8,6 +8,7 @@ WORKFLOWS = ROOT / ".github" / "workflows" PINNED_CODEX_SHA = "61a44880a85d2fd0d8770908dea5733495e571c8" PINNED_CLAUDE_CODE = "2.1.220" +PINNED_COVERAGE = "7.15.2" SETUP_PYTHON_ACTION = "actions/setup-python@v7" @@ -30,6 +31,24 @@ def test_blocking_ci_validates_the_claude_code_plugin(self) -> None: self.assertIn("claude plugin validate plugins/hotspot-to-rq --strict", workflow) self.assertIn("claude plugin validate . --strict", workflow) + def test_python_matrix_reports_branch_coverage_without_a_threshold(self) -> None: + workflow = (WORKFLOWS / "plugin-ci.yml").read_text(encoding="utf-8") + test_script = (ROOT / "scripts" / "test_plugin.sh").read_text( + encoding="utf-8" + ) + coverage_config = (ROOT / ".coveragerc").read_text(encoding="utf-8") + self.assertIn(f"coverage=={PINNED_COVERAGE}", workflow) + self.assertIn('PYTHON_COVERAGE: "1"', workflow) + self.assertIn("python -m coverage report --show-missing", workflow) + self.assertIn("python -m coverage xml", workflow) + self.assertIn("python -m coverage html", workflow) + self.assertIn("actions/upload-artifact@v4", workflow) + self.assertIn("PYTHON_COVERAGE", test_script) + self.assertIn("-m coverage run --branch", test_script) + self.assertIn("branch = True", coverage_config) + self.assertNotIn("fail-under", workflow) + self.assertNotIn("fail_under", coverage_config) + def test_scheduled_compatibility_ci_tracks_main_without_pr_trigger(self) -> None: workflow = (WORKFLOWS / "upstream-codex-compat.yml").read_text( encoding="utf-8" @@ -42,11 +61,13 @@ def test_scheduled_compatibility_ci_tracks_main_without_pr_trigger(self) -> None self.assertNotIn("pull_request:", workflow) self.assertNotIn("continue-on-error", workflow) - def test_paper_notes_sync_is_scheduled_manual_and_writes_only_when_needed(self) -> None: + def test_paper_notes_sync_is_scheduled_manual_and_uses_a_gated_pr(self) -> None: workflow = (WORKFLOWS / "sync-paper-notes.yml").read_text(encoding="utf-8") self.assertIn('cron: "17 7 * * 1"', workflow) self.assertIn("workflow_dispatch:", workflow) + self.assertIn("actions: write", workflow) self.assertIn("contents: write", workflow) + self.assertIn("pull-requests: write", workflow) self.assertIn("group: sync-paper-notes-main", workflow) self.assertIn("PAPER_NOTES_CACHE_DIR: ${{ runner.temp }}/paper-notes-upstream", workflow) self.assertIn("bash scripts/sync_paper_notes.sh", workflow) @@ -56,7 +77,14 @@ def test_paper_notes_sync_is_scheduled_manual_and_writes_only_when_needed(self) ) self.assertNotIn("git diff --cached --check\n", workflow) self.assertIn("git commit --quiet", workflow) - self.assertIn("git push origin HEAD:main", workflow) + self.assertIn("SYNC_BRANCH: automation/paper-notes-sync", workflow) + self.assertIn("git push --force-with-lease", workflow) + self.assertIn("gh pr create", workflow) + self.assertIn('gh pr edit "$pr_url"', workflow) + self.assertIn("gh workflow run plugin-ci.yml", workflow) + self.assertIn('gh pr merge "$PR_URL"', workflow) + self.assertIn("--auto --squash --delete-branch", workflow) + self.assertNotIn("git push origin HEAD:main", workflow) if __name__ == "__main__": diff --git a/tests/test_codex_dispatch.py b/tests/test_codex_dispatch.py index 0ed4c649..794af34e 100644 --- a/tests/test_codex_dispatch.py +++ b/tests/test_codex_dispatch.py @@ -9,6 +9,8 @@ from pathlib import Path from unittest import mock +from fixture_builders import build_schema14_session, write_dispatch_draft + ROOT = Path(__file__).resolve().parents[1] SCRIPTS = ( @@ -70,66 +72,23 @@ def write_draft( inline_payload: object | None = None, search_budget: dict | None = None, ) -> None: - draft = { - "envelope": { - "schema_version": "1.0", - "session_id": "session-1", - "project_root": str(session.parent.resolve()), - "project_snapshot": "snapshot-1", - "phase": phase, - "role": role, - "candidate_id": candidate_id, - "round": round_number, - "packet_id": packet_id, - }, - "role_instructions": role_instructions, - "inline_payload": ( - {"candidate": candidate_id} - if inline_payload is None - else inline_payload - ), - "search_budget": search_budget, - } - (session / "control-inputs" / "dispatch-drafts" / f"{packet_id}.json").write_text( - json.dumps(draft, ensure_ascii=False, indent=2) + "\n", - encoding="utf-8", + write_dispatch_draft( + session, + packet_id=packet_id, + candidate_id=candidate_id, + phase=phase, + role=role, + round_number=round_number, + role_instructions=role_instructions, + inline_payload=inline_payload, + search_budget=search_budget, ) def make_session(self, root: Path, packet_id: str = "C01-R1-MENTOR") -> Path: - session = root / "session-1" - (session / "control-inputs" / "dispatch-drafts").mkdir(parents=True) - (session / "project-evidence-pack.md").write_text( - "Observed signal A\n", - encoding="utf-8", - ) - (session / "session-state.json").write_text( - json.dumps( - { - "schema_version": "1.4", - "transport_profile": "CODEX", - "session_id": "session-1", - "project_root": str(root.resolve()), - "project_snapshot": "snapshot-1", - "search_budget": { - "profile": "standard", - "large_downloads": [], - "approved_extensions": [], - }, - "accepted_work_products": [], - }, - ensure_ascii=False, - indent=2, - ) - + "\n", - encoding="utf-8", - ) - (session / "control-input.json").write_text("{}\n", encoding="utf-8") - self.write_draft( - session, + return build_schema14_session( + root, packet_id=packet_id, - candidate_id="C01", ) - return session def build( self, @@ -924,6 +883,79 @@ def mutate_after_commit_check( "MAP-001", ) + def test_codex_guided_role_result_completes_a_committed_dispatch( + self, + ) -> None: + """Exercise the real builder/validator path through role acceptance.""" + with tempfile.TemporaryDirectory() as temporary: + session = self.make_session(Path(temporary)) + self.write_valid_nonempty_controller_batch(session) + self.assertEqual( + [], + batch_validator.validate_batch( + session, + "controller-output.json", + ), + ) + self.commit_current_controller_transition(session) + persisted_path = ( + session + / "control-inputs" + / "dispatches" + / "MAP-001.json" + ) + persisted = persisted_path.read_bytes() + self.assertEqual( + persisted, + batch_validator.load_committed_packet_bytes( + session, + "CTRL-0002", + "MAP-001", + ), + ) + + packet = json.loads( + persisted_path.read_text(encoding="utf-8") + ) + envelope = packet["envelope"] + product = { + field: envelope[field] + for field in ( + "packet_id", + "phase", + "role", + "session_id", + "project_root", + "project_snapshot", + "candidate_id", + "round", + ) + } + product["context_fingerprint"] = ( + dispatch_builder.session_validator.expected_context_fingerprint( + product + ) + ) + state_path = session / "session-state.json" + state = json.loads(state_path.read_text(encoding="utf-8")) + state["accepted_work_products"].append(product) + state_path.write_text( + json.dumps(state, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + + self.assertEqual([], self.session_validation_errors(session)) + with self.assertRaisesRegex( + batch_validator.BatchError, + "already accepted or rejected", + ): + batch_validator.load_committed_packet_bytes( + session, + "CTRL-0002", + "MAP-001", + ) + self.assertEqual(persisted, persisted_path.read_bytes()) + def test_full_session_requires_a_committed_codex_batch_for_dispatches( self, ) -> None: diff --git a/tests/test_mainline_control.py b/tests/test_mainline_control.py index 30f1162b..52142a92 100644 --- a/tests/test_mainline_control.py +++ b/tests/test_mainline_control.py @@ -10,6 +10,8 @@ import unittest from pathlib import Path +from fixture_builders import write_claude_dispatch + ROOT = Path(__file__).resolve().parents[1] VALIDATOR_PATH = ( @@ -1695,6 +1697,94 @@ def test_evidence_intake_can_record_explicit_missing_target_fields(self) -> None any("PRIMARY_CLAIM" in error for error in errors) ) + def test_complete_evaluation_requires_decision_receipt_and_next_plan( + self, + ) -> None: + state = { + "mode": "evaluate", + "status": "COMPLETE", + "evaluation_target": { + "direction": "Reliable tool-using agents", + "primary_claim": "The repair improves task completion.", + "study_type": "controlled experiment", + "constraints": ["single GPU"], + }, + "experiment_inventory": [ + { + "experiment_id": "EXP-1", + "hypothesis": "The repair improves completion.", + "artifact_paths": ["results/exp-1.json"], + "outcome_summary": "Improved on the held-out split.", + "status": "OBSERVED", + } + ], + "claim_evidence_matrix": [ + { + "claim_id": "CLAIM-1", + "claim": "Completion improves.", + "evidence_ids": ["EXP-1"], + "support_status": "SUPPORTED", + "limitations": ["One benchmark"], + } + ], + "evaluation_rounds": [ + { + "round": 1, + "verdict": "CONVERGED", + "confidence": "medium", + "search_usage": { + "query_batches": 0, + "queries": 0, + "sources_inspected": 0, + "budget_extension": None, + }, + } + ], + "evaluation_decision": { + "verdict": "CONTINUE", + "confidence": "medium", + "rationale": "The controlled result supports one more test.", + "decisive_evidence": ["EXP-1"], + "strongest_objection": "External validity remains unknown.", + "unresolved": ["Second benchmark"], + "next_action": "Run the minimal cross-benchmark check.", + }, + "next_experiment": { + "action": "RUN", + "question": "Does the gain transfer?", + "design": "Repeat on one independent benchmark.", + "expected_outcomes": ["transfer", "no transfer"], + "decision_rule": "Continue only if the gain is positive.", + "resource_requirements": ["single GPU"], + "stop_condition": "Stop after the registered comparison.", + }, + "min_rounds": 1, + "max_rounds": 6, + "user_required": [], + "gate_receipts": [ + { + "receipt_id": "GATE-EVAL-1", + "gate": "EVALUATION_DECISION", + "action": "CONFIRM", + "values": ["CONTINUE"], + "based_on_revision": 4, + "received_at": "2026-07-28T12:00:00+08:00", + } + ], + } + errors: list[str] = [] + validator.validate_evaluation_state(state, errors) + controller_validator.validate_completion(state, [], errors) + self.assertEqual([], errors) + + state["gate_receipts"] = [] + errors = [] + controller_validator.validate_completion(state, [], errors) + self.assertTrue( + any("EVALUATION_DECISION receipt" in error for error in errors), + errors, + ) + def test_round_configuration_rejects_json_float(self) -> None: with tempfile.TemporaryDirectory() as temporary: session_dir = Path(temporary) / "session-1" @@ -2775,6 +2865,78 @@ def test_claude_controller_gate_validates_transport_content( errors, ) + def test_claude_persisted_transport_passes_then_detects_mutation( + self, + ) -> None: + with tempfile.TemporaryDirectory() as temporary: + session_dir = Path(temporary) + (session_dir / "project-evidence-pack.md").write_text( + "bounded evidence\n", + encoding="utf-8", + ) + state = { + "schema_version": "1.4", + "transport_profile": "CLAUDE", + "session_id": "session-1", + "project_root": str(session_dir.parent.resolve()), + "project_snapshot": "snapshot-1", + "mode": "discover", + "status": "SCANNING", + "max_rounds": 6, + "candidates": [], + "search_budget": { + "profile": "standard", + "large_downloads": [], + "approved_extensions": [], + }, + "accepted_work_products": [], + "mainline_control": {"transition_log": []}, + } + dispatch = write_claude_dispatch(session_dir, state) + errors: list[str] = [] + controller_validator.validate_dispatches( + [dispatch], + state, + None, + "ADVANCE", + None, + "SCANNING", + "PHASE_BOUNDARY", + [], + {}, + {}, + session_dir, + errors, + ) + self.assertEqual([], errors) + + transport_path = session_dir / dispatch["transport_path"] + transport_path.chmod(0o600) + transport_path.write_text("{}\n", encoding="utf-8") + transport_path.chmod(0o400) + errors = [] + controller_validator.validate_dispatches( + [dispatch], + state, + None, + "ADVANCE", + None, + "SCANNING", + "PHASE_BOUNDARY", + [], + {}, + {}, + session_dir, + errors, + ) + self.assertTrue( + any( + "transport_sha256 does not match" in error + for error in errors + ), + errors, + ) + def test_full_validator_rejects_unhashable_transport_profile(self) -> None: state = control_state(transitions=[], products=[]) state.update( diff --git a/tests/test_paper_note_reports.py b/tests/test_paper_note_reports.py new file mode 100644 index 00000000..4e3bafe7 --- /dev/null +++ b/tests/test_paper_note_reports.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +from collections import Counter +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest import mock + +from fixture_builders import build_mini_paper_notes, initialize_upstream_repository + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPTS = ROOT / "scripts" +SYNC_SCRIPT = SCRIPTS / "sync_paper_notes.sh" + + +def load_script(name: str): + spec = importlib.util.spec_from_file_location(name, SCRIPTS / f"{name}.py") + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +analyzer = load_script("analyze_paper_notes") +trend = load_script("build_trend_report") + + +class PaperNoteReportTests(unittest.TestCase): + def test_frontmatter_accepts_scalars_and_rejects_unclosed_blocks(self) -> None: + self.assertEqual( + {"title": "中文标题", "code": "value"}, + analyzer.frontmatter( + '---\ntitle: "中文标题"\ncode: value\n---\n正文\n' + ), + ) + self.assertEqual({}, analyzer.frontmatter("---\ntitle: incomplete\n")) + self.assertEqual({}, analyzer.frontmatter("title: absent delimiters\n")) + + def test_analysis_filters_paths_counts_topics_and_caps_examples(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + docs = build_mini_paper_notes(Path(temporary)) + report = analyzer.analyze_corpus(docs) + + self.assertEqual(12, report["paper_files"]) + self.assertEqual( + [["CVPR2026", 6], ["CVPR2025", 4], ["ACL2026", 2]], + _json_rows(report["conferences"]), + ) + self.assertEqual( + [["vision", 10], ["alpha", 1], ["zeta", 1]], + _json_rows(report["areas"]), + ) + topics = dict(report["high_frequency_topics"]) + self.assertEqual(5, topics["生成模型"]) + self.assertEqual(3, topics["可靠性"]) + self.assertEqual(2, topics["具身智能"]) + self.assertNotIn("不应出现", topics) + self.assertEqual(3, len(report["examples"]["vision"])) + self.assertEqual("统一视觉标题 续行", report["examples"]["vision"][0]) + + def test_analysis_and_markdown_are_byte_stable(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + docs = build_mini_paper_notes(root) + json_path = root / "first" / "report.json" + markdown_path = root / "first" / "report.md" + + self.assertEqual(0, analyzer.main([str(docs), str(json_path)])) + self.assertEqual(0, trend.main([str(docs), str(markdown_path)])) + first_json = json_path.read_bytes() + first_markdown = markdown_path.read_bytes() + + self.assertEqual(0, analyzer.main([str(docs), str(json_path)])) + self.assertEqual(0, trend.main([str(docs), str(markdown_path)])) + self.assertEqual(first_json, json_path.read_bytes()) + self.assertEqual(first_markdown, markdown_path.read_bytes()) + + def test_trend_report_applies_threshold_delta_and_stable_ties(self) -> None: + counts = { + "CVPR2025": Counter({"zeta": 4, "alpha": 4, "falling": 7}), + "CVPR2026": Counter({"zeta": 6, "alpha": 6, "falling": 1}), + } + report = trend.render_report(counts) + alpha = "| +2 | `alpha` | 4 | 6 |" + zeta = "| +2 | `zeta` | 4 | 6 |" + self.assertLess(report.index(alpha), report.index(zeta)) + self.assertIn("| -6 | `falling` | 7 | 1 |", report) + self.assertNotIn("below-threshold", report) + + def test_empty_corpus_creates_both_reports(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + docs = root / "empty" + docs.mkdir() + json_path = root / "nested" / "report.json" + markdown_path = root / "nested" / "report.md" + + self.assertEqual(0, analyzer.main([str(docs), str(json_path)])) + self.assertEqual(0, trend.main([str(docs), str(markdown_path)])) + + payload = json.loads(json_path.read_text(encoding="utf-8")) + self.assertEqual(0, payload["paper_files"]) + self.assertEqual([], payload["areas"]) + self.assertIn("当前共统计 **0 篇论文**", markdown_path.read_text()) + + def test_symlinked_markdown_outside_root_is_never_read(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + docs = root / "docs" + docs.mkdir() + outside = root / "secret.md" + outside.write_text("高频主题:泄露×99\n", encoding="utf-8") + link = docs / "CVPR2026" / "vision" / "paper.md" + link.parent.mkdir(parents=True) + link.symlink_to(outside) + + with mock.patch.object( + Path, + "read_text", + side_effect=AssertionError("symlink target was read"), + ): + report = analyzer.analyze_corpus(docs) + counts = trend.collect_counts(docs) + + self.assertEqual(0, report["paper_files"]) + self.assertEqual({}, counts) + + def test_local_sync_analysis_and_trend_pipeline(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + upstream = initialize_upstream_repository(root) + mirror = root / "mirror" + cache = root / "cache" + env = os.environ.copy() + env.update( + { + "PAPER_NOTES_UPSTREAM_URL": str(upstream), + "PAPER_NOTES_CACHE_DIR": str(cache), + } + ) + sync = subprocess.run( + ["bash", str(SYNC_SCRIPT), str(mirror)], + cwd=ROOT, + env=env, + check=False, + text=True, + capture_output=True, + ) + self.assertEqual(0, sync.returncode, sync.stderr) + + json_path = root / "reports" / "trends.json" + markdown_path = root / "reports" / "trends.md" + self.assertEqual( + 0, + analyzer.main([str(mirror / "docs"), str(json_path)]), + ) + self.assertEqual( + 0, + trend.main([str(mirror / "docs"), str(markdown_path)]), + ) + payload = json.loads(json_path.read_text(encoding="utf-8")) + self.assertEqual(12, payload["paper_files"]) + self.assertIn( + "### CVPR(4 → 6)", + markdown_path.read_text(encoding="utf-8"), + ) + self.assertTrue((mirror / "UPSTREAM.md").is_file()) + + +def _json_rows(value: object) -> object: + """Normalize tuple rows to their JSON representation for readable assertions.""" + return json.loads(json.dumps(value, ensure_ascii=False)) + + +if __name__ == "__main__": + unittest.main()