#!/usr/bin/env python3 """changelog_build.py — 从 fragment 源生成三层 changelog 视图。 设计目标(解决多人协作 + git merge 冲突): - 唯一数据源是 ``.cursor/changelog/entries//.md`` 一条一文件的 fragment。 - 三层视图(full / recent / headlines)全部由本脚本**确定性生成**,不再手写。 - 因为排序只依赖 fragment 内的 CL 时间戳,与 git merge 顺序无关,所有人重建后逐字节收敛。 只用标准库,兼容 Python 3.10+,可在任意平台运行。 """ from __future__ import annotations import argparse import re import sys from dataclasses import dataclass, field from pathlib import Path # ---- 路径约定 -------------------------------------------------------------- CHANGELOG_DIR_NAME = ".cursor/changelog" ENTRIES_SUBDIR = "entries" FULL_NAME = "changelog-full.md" RECENT_NAME = "changelog-recent.md" HEADLINES_NAME = "changelog-headlines.md" BY_AUTHOR_NAME = "changelog-by-author.md" RECENT_WINDOW = 20 HEADLINES_WINDOW = 50 # ---- fragment 解析 --------------------------------------------------------- ID_RE = re.compile(r"^CL-(\d{8})-(\d{3,4})([a-z]*)(?:-(.+))?$") FM_FENCE = "---" SECTION_RE = re.compile(r"^\s*$", re.M) @dataclass class Fragment: fid: str date: str = "" author: str = "legacy" author_name: str = "" type: str = "" merge_source: str = "" status: str = "active" superseded_by: str = "" title: str = "" source_chat: str = "" tags: list[str] = field(default_factory=list) affected_files: list[str] = field(default_factory=list) l3: str = "" l2: str = "" l1: str = "" path: Path | None = None def sort_key(self) -> tuple[str, str, str, str]: m = ID_RE.match(self.fid) if not m: return (self.fid, "", "", "") date8, time4, suffix, _author = m.groups() return (date8, time4.zfill(4), suffix, self.fid) def date_only(self) -> str: return self.date.split(" ")[0] if self.date else "" def _parse_frontmatter(text: str) -> dict[str, object]: data: dict[str, object] = {} lines = text.splitlines() i = 0 while i < len(lines): line = lines[i] if not line.strip(): i += 1 continue m = re.match(r"^([A-Za-z_]+):\s*(.*)$", line) if not m: i += 1 continue key, val = m.group(1), m.group(2).strip() if val == "": items: list[str] = [] j = i + 1 while j < len(lines) and re.match(r"^\s+-\s+", lines[j]): items.append(lines[j].strip()[1:].strip()) j += 1 if items: data[key] = items i = j else: data[key] = "" i += 1 elif val.startswith("[") and val.endswith("]"): inner = val[1:-1].strip() data[key] = [x.strip() for x in inner.split(",") if x.strip()] if inner else [] i += 1 else: data[key] = val i += 1 return data def parse_fragment(path: Path) -> Fragment: raw = path.read_text(encoding="utf-8") if not raw.startswith(FM_FENCE): raise ValueError(f"fragment 缺少 frontmatter: {path}") rest = raw[len(FM_FENCE):] end = rest.find("\n" + FM_FENCE) if end == -1: raise ValueError(f"fragment frontmatter 未闭合: {path}") fm_text = rest[:end] body = rest[end + len("\n" + FM_FENCE):].lstrip("\n") fm = _parse_frontmatter(fm_text) sections: dict[str, str] = {} parts = SECTION_RE.split(body) it = iter(parts[1:]) for marker, content in zip(it, it): sections[marker] = content.strip("\n") def _s(key: str) -> str: v = fm.get(key, "") return v if isinstance(v, str) else "" def _l(key: str) -> list[str]: v = fm.get(key, []) return [str(x) for x in v] if isinstance(v, list) else [] frag = Fragment( fid=_s("id"), date=_s("date"), author=_s("author") or "legacy", author_name=_s("author_name"), type=_s("type"), merge_source=_s("merge_source"), status=_s("status") or "active", superseded_by=_s("superseded_by"), title=_s("title"), source_chat=_s("source_chat"), tags=_l("tags"), affected_files=_l("affected_files"), l3=sections.get("L3", "").strip(), l2=sections.get("L2", "").strip(), l1=sections.get("L1", "").strip(), path=path, ) if not frag.fid: raise ValueError(f"fragment 缺少 id: {path}") return frag def load_fragments(entries_dir: Path) -> list[Fragment]: frags: list[Fragment] = [] seen: dict[str, Path] = {} for path in sorted(entries_dir.rglob("*.md")): frag = parse_fragment(path) if frag.fid in seen: raise ValueError( f"重复的 CL id {frag.fid}: {path} 与 {seen[frag.fid]}" ) seen[frag.fid] = path frags.append(frag) frags.sort(key=lambda f: f.sort_key(), reverse=True) return frags # ---- 渲染 ------------------------------------------------------------------ def _author_tag(frag: Fragment) -> str: return "" if frag.author in ("", "legacy") else f"@{frag.author} " def render_headlines(frags: list[Fragment]) -> str: head = ( "# Dev Changelog — Headlines\n\n" "最近 ~50 次改动的一句话概要,按时间倒序排列。每次会话自动注入上下文。\n" "> 本文件由 `tools/changelog_build.py` 从 `entries/` 自动生成,请勿手改。" ) lines = [] for frag in frags[:HEADLINES_WINDOW]: merge = f" [merge:{frag.merge_source}]" if frag.merge_source else "" text = frag.l3 or frag.title lines.append(f"- [{frag.fid}] {_author_tag(frag)}{text}{merge}") body = "\n\n".join(lines) return head + (f"\n\n{body}" if body else "") + "\n" def render_recent(frags: list[Fragment]) -> str: head = ( "# Dev Changelog — Recent\n\n" "最近 ~20 次改动的摘要记录,按时间倒序排列。\n" "当 Agent 检测到当前任务与近期改动相关时自动读取。\n" "> 本文件由 `tools/changelog_build.py` 从 `entries/` 自动生成,请勿手改。" ) blocks = [] for frag in frags[:RECENT_WINDOW]: b = [f"### [{frag.fid}] {frag.date_only()} — {frag.title}"] if frag.merge_source: b.append(f"- **merge_source**: {frag.merge_source}") if frag.author and frag.author != "legacy": b.append(f"- **author**: {frag.author}") if frag.tags: b.append(f"- **tags**: {', '.join(frag.tags)}") if frag.affected_files: b.append(f"- **affected_files**: {'、'.join(frag.affected_files)}") if frag.l2: b.append(f"- **summary**: {frag.l2}") if frag.source_chat: b.append(f"- **source_chat**: {frag.source_chat}") blocks.append("\n".join(b)) body = "\n\n".join(blocks) return head + (f"\n\n{body}" if body else "") + "\n" def render_full(frags: list[Fragment]) -> str: head = ( "# Dev Changelog — Full\n\n" "完整的开发改动记录,按时间倒序排列。作为主动 RAG 的数据源,用户手动唤醒时读取。\n" "> 本文件由 `tools/changelog_build.py` 从 `entries/` 自动生成,请勿手改;" "新增/修改请编辑 `entries//.md` 后重跑生成器。\n\n" "## 记录" ) blocks = [] for frag in frags: b = [f"### [{frag.fid}] {frag.date} — {frag.title}"] if frag.merge_source: b.append(f"- **merge_source**: {frag.merge_source}") author_line = frag.author if frag.author_name: author_line = f"{frag.author} ({frag.author_name})" if frag.author and frag.author != "legacy": b.append(f"- **author**: {author_line}") if frag.type: b.append(f"- **type**: {frag.type}") if frag.status and frag.status != "active": sup = f" → {frag.superseded_by}" if frag.superseded_by else "" b.append(f"- **status**: {frag.status}{sup}") if frag.tags: b.append(f"- **tags**: {', '.join(frag.tags)}") if frag.affected_files: af = "\n".join(f" - {x}" for x in frag.affected_files) b.append(f"- **affected_files**:\n{af}") if frag.l1: b.append(frag.l1) if frag.source_chat: b.append(f"- **source_chat**: {frag.source_chat}") blocks.append("\n".join(b)) body = "\n\n".join(blocks) return head + (f"\n\n{body}" if body else "") + "\n" def render_by_author(frags: list[Fragment]) -> str: head = ( "# Dev Changelog — By Author\n\n" "按作者聚合的贡献概览,便于多人协作时区分提交来源。\n" "> 本文件由 `tools/changelog_build.py` 自动生成,请勿手改。" ) by: dict[str, list[Fragment]] = {} for frag in frags: by.setdefault(frag.author or "legacy", []).append(frag) blocks = [] for author in sorted(by): items = by[author] name = next((f.author_name for f in items if f.author_name), "") title = f"## {author}" + (f" ({name})" if name else "") + f" — {len(items)} 条" rows = [ f"- [{f.fid}] {f.date_only()} — {f.title}" for f in items[:HEADLINES_WINDOW] ] blocks.append(title + "\n" + "\n".join(rows)) body = "\n\n".join(blocks) return head + (f"\n\n{body}" if body else "") + "\n" # ---- 入口 ------------------------------------------------------------------ def find_changelog_dir(start: Path) -> Path: cur = start.resolve() for cand in [cur, *cur.parents]: d = cand / CHANGELOG_DIR_NAME if d.is_dir(): return d # 脚本自身位于 .cursor/changelog/tools/ 下时回退 here = Path(__file__).resolve().parent.parent if here.name == "changelog": return here raise SystemExit("找不到 .cursor/changelog 目录") def build(changelog_dir: Path, check: bool = False) -> int: entries_dir = changelog_dir / ENTRIES_SUBDIR if not entries_dir.is_dir(): raise SystemExit(f"entries 目录不存在: {entries_dir}") frags = load_fragments(entries_dir) targets = { HEADLINES_NAME: render_headlines(frags), RECENT_NAME: render_recent(frags), FULL_NAME: render_full(frags), BY_AUTHOR_NAME: render_by_author(frags), } drift = False for name, content in targets.items(): path = changelog_dir / name old = path.read_text(encoding="utf-8") if path.exists() else None if old != content: drift = True if check: print(f"[drift] {name} 与 fragment 源不一致") else: path.write_text(content, encoding="utf-8", newline="\n") print(f"[write] {name}") if check: if drift: print("视图与 fragment 源不一致,请运行 changelog_build.py 重建。") return 1 print("视图已与 fragment 源同步。") return 0 print(f"完成:{len(frags)} 条 fragment → 4 个视图。") return 0 def main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser(description="从 fragment 源生成三层 changelog 视图") ap.add_argument("changelog_dir", nargs="?", default=None, help=".cursor/changelog 路径") ap.add_argument("--check", action="store_true", help="只校验是否漂移,不写文件(CI 用)") args = ap.parse_args(argv) changelog_dir = ( Path(args.changelog_dir) if args.changelog_dir else find_changelog_dir(Path.cwd()) ) return build(changelog_dir, check=args.check) if __name__ == "__main__": sys.exit(main())