Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
@@ -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
23 changes: 22 additions & 1 deletion .github/workflows/plugin-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 60 additions & 1 deletion .github/workflows/sync-paper-notes.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ on:
workflow_dispatch:

permissions:
actions: write
contents: write
pull-requests: write

concurrency:
group: sync-paper-notes-main
Expand All @@ -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.
Expand All @@ -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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,7 @@ reports/
# Python and operating-system artifacts
__pycache__/
*.py[cod]
.coverage
coverage.xml
htmlcov/
.DS_Store
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 确认
Expand Down Expand Up @@ -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。
190 changes: 131 additions & 59 deletions scripts/analyze_paper_notes.py
Original file line number Diff line number Diff line change
@@ -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())
Loading
Loading