Files
UnityAI/hooks/sync_doc_associations.py

219 lines
7.4 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
# hooks/sync_doc_associations.py
#
# 当 md 文件变更时,自动在关联文档的标准位置追加变更提醒:
# - 有 ## 变更记录 表格的模块文档:追加一行变更记录
# - 有 ## 下一步 清单的 progress/tasks 文档:追加一个待检查项
# - 其他文档:追加 ## 最近关联变更 章节
#
# 本脚本只追加、不覆盖正文;执行后请通过 git diff 人工确认。
import os
import re
import subprocess
import sys
from datetime import datetime
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
EXCLUDED_PREFIXES = (
'.claude/', '.codex/', '.git/',
'Library/', 'Temp/', 'Logs/', 'UserSettings/',
'obj/', 'bin/', 'HybridCLRData/',
'Assets/ThirdPlugins/',
'Assets/.WX-WASM-SDK-V2/',
'Packages/',
)
def run_git(args):
result = subprocess.run(
["git"] + args,
cwd=PROJECT_ROOT,
capture_output=True,
text=True,
encoding="utf-8",
)
return result.stdout.strip(), result.returncode
def get_changed_md_files():
"""获取 tracked 改动 + untracked 新 md 文件。"""
changed, code = run_git(["diff", "--name-only"])
if code != 0 or not changed:
changed = ""
untracked, code = run_git(["ls-files", "--others", "--exclude-standard"])
if code == 0 and untracked:
changed = (changed + "\n" + untracked).strip()
if not changed:
return []
files = [line.strip() for line in changed.splitlines() if line.strip().endswith(".md")]
files = [f for f in files if not f.startswith(EXCLUDED_PREFIXES)]
return sorted(set(files))
def resolve_link(source_file: Path, link: str):
"""解析相对链接为项目相对路径。"""
if re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*://", link):
return None
if link.startswith("#"):
return None
link = link.split("#")[0]
if not link:
return None
target = (source_file.parent / link).resolve()
try:
target.relative_to(PROJECT_ROOT)
except ValueError:
return None
rel = target.relative_to(PROJECT_ROOT)
return str(rel).replace("\\", "/")
def extract_associations(md_path: Path):
"""读取 md 的 ## 文档关联 章节,返回关联文件相对路径列表。"""
content = md_path.read_text(encoding="utf-8")
in_section = False
targets = []
for line in content.splitlines():
if re.match(r"^##\s+文档关联\s*$", line):
in_section = True
continue
if in_section and re.match(r"^##\s+", line):
break
if in_section:
for _, link in re.findall(r"\[([^\]]*)\]\(([^\)]+)\)", line):
resolved = resolve_link(md_path, link)
if resolved:
targets.append(resolved)
return targets
def find_section_range(lines, title_re):
"""返回 [start, end) 行号end 为下一个 ## 标题或文件末尾。"""
start = None
for i, line in enumerate(lines):
if re.match(title_re, line):
start = i
continue
if start is not None and re.match(r"^##\s+", line):
return start, i
if start is not None:
return start, len(lines)
return None, None
def append_changelog(target_path: Path, source_rel: str, date_str: str) -> bool:
"""在 ## 变更记录 表格追加一行;成功返回 True。"""
content = target_path.read_text(encoding="utf-8")
lines = content.splitlines()
start, end = find_section_range(lines, r"^##\s+变更记录\s*$")
if start is None:
return False
# 找到表格最后一行(跳过标题和分隔线)
insert_pos = end
for i in range(start + 1, end):
if lines[i].strip().startswith("|") and not re.match(r"^\|[-:\s|]+\|$", lines[i].strip()):
insert_pos = i + 1
new_line = f"| {date_str} | [{source_rel}](../../{source_rel}) 发生变更 | 待补充影响范围 | 待确认 |"
lines.insert(insert_pos, new_line)
target_path.write_text("\n".join(lines) + ("\n" if content.endswith("\n") else ""), encoding="utf-8")
return True
def append_next_step(target_path: Path, source_rel: str) -> bool:
"""在 ## 下一步 清单追加一个待检查项;成功返回 True。"""
content = target_path.read_text(encoding="utf-8")
lines = content.splitlines()
start, end = find_section_range(lines, r"^##\s+下一步\s*$")
if start is None:
return False
new_line = f"- [ ] 检查 [{source_rel}](../../{source_rel}) 的变更是否影响本文档"
# 避免重复
for line in lines[start:end]:
if source_rel in line and "变更是否影响本文档" in line:
return True # 已存在,视为成功但不写入
lines.insert(end, new_line)
target_path.write_text("\n".join(lines) + ("\n" if content.endswith("\n") else ""), encoding="utf-8")
return True
def append_recent_changes_section(target_path: Path, source_rel: str, date_str: str) -> bool:
"""在没有 变更记录/下一步 的文档末尾追加 ## 最近关联变更。"""
content = target_path.read_text(encoding="utf-8")
# 生成从 target 到 source 的相对链接
try:
link = os.path.relpath(PROJECT_ROOT / source_rel, target_path.parent).replace("\\", "/")
except ValueError:
link = source_rel
if "## 最近关联变更" in content:
lines = content.splitlines()
start, end = find_section_range(lines, r"^##\s+最近关联变更\s*$")
if start is not None:
new_line = f"- {date_str}:关联文档 [{source_rel}]({link}) 发生变更,需同步检查。"
lines.insert(end, new_line)
target_path.write_text("\n".join(lines) + ("\n" if content.endswith("\n") else ""), encoding="utf-8")
return True
section = (
"\n---\n\n"
"## 最近关联变更\n\n"
f"- {date_str}:关联文档 [{source_rel}]({link}) 发生变更,需同步检查。\n"
)
target_path.write_text(content.rstrip() + section, encoding="utf-8")
return True
def sync_associations():
changed_files = get_changed_md_files()
if not changed_files:
print("[sync-doc-assoc] No .md changes detected.")
return 0
date_str = datetime.now().strftime("%Y-%m-%d")
modified = []
for source_rel in changed_files:
source_path = PROJECT_ROOT / source_rel
if not source_path.exists():
continue
targets = extract_associations(source_path)
for target_rel in targets:
target_path = PROJECT_ROOT / target_rel
if not target_path.is_file():
# Skip directories, globs, or missing targets
continue
if target_rel == source_rel:
continue
done = (
append_changelog(target_path, source_rel, date_str)
or append_next_step(target_path, source_rel)
or append_recent_changes_section(target_path, source_rel, date_str)
)
if done:
modified.append((source_rel, target_rel))
if not modified:
print("[sync-doc-assoc] No associated docs need updating.")
return 0
print("[sync-doc-assoc] Updated associated docs:")
for source, target in modified:
print(f" - {source} -> {target}")
print("\n[sync-doc-assoc] Please review the changes with git diff before committing.")
return 0
if __name__ == "__main__":
sys.exit(sync_associations())