cursor Init 完善多人协同changelog,以及godot相关基础skill和代码规范
This commit is contained in:
336
.cursor/changelog/tools/changelog_build.py
Normal file
336
.cursor/changelog/tools/changelog_build.py
Normal file
@@ -0,0 +1,336 @@
|
||||
#!/usr/bin/env python3
|
||||
"""changelog_build.py — 从 fragment 源生成三层 changelog 视图。
|
||||
|
||||
设计目标(解决多人协作 + git merge 冲突):
|
||||
- 唯一数据源是 ``.cursor/changelog/entries/<author>/<id>.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*(L1|L2|L3)\s*-->\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/<author>/<id>.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())
|
||||
Reference in New Issue
Block a user