209 lines
6.5 KiB
Python
209 lines
6.5 KiB
Python
#!/usr/bin/env python3
|
||
"""校验 Vibe Coding 文档结构、链接、占位符和功能追溯。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import re
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
|
||
PLACEHOLDER = re.compile(r"\{\{[^{}\n]+\}\}")
|
||
MARKDOWN_LINK = re.compile(r"\[[^\]]+\]\(([^)]+)\)")
|
||
FUNCTION_ID = re.compile(r"\bF-\d{3}\b")
|
||
OLD_NAMES = {
|
||
"03.冒烟自测用例.md",
|
||
"06.逻辑验证报告.md",
|
||
"07.验收与交付报告.md",
|
||
}
|
||
|
||
|
||
class Report:
|
||
def __init__(self) -> None:
|
||
self.errors: list[str] = []
|
||
self.warnings: list[str] = []
|
||
|
||
def error(self, message: str) -> None:
|
||
self.errors.append(message)
|
||
|
||
def warning(self, message: str) -> None:
|
||
self.warnings.append(message)
|
||
|
||
|
||
def read_text(path: Path, report: Report) -> str:
|
||
try:
|
||
return path.read_text(encoding="utf-8")
|
||
except UnicodeDecodeError:
|
||
report.error(f"文件不是有效 UTF-8:{path}")
|
||
except OSError as exc:
|
||
report.error(f"无法读取文件:{path}({exc})")
|
||
return ""
|
||
|
||
|
||
def check_links(path: Path, text: str, report: Report) -> None:
|
||
for match in MARKDOWN_LINK.finditer(text):
|
||
target = match.group(1).strip()
|
||
if (
|
||
not target
|
||
or target.startswith(("#", "http://", "https://", "mailto:"))
|
||
or "{{" in target
|
||
):
|
||
continue
|
||
relative = target.split("#", 1)[0]
|
||
if not (path.parent / relative).resolve().exists():
|
||
report.error(f"失效链接:{path} -> {target}")
|
||
|
||
|
||
def required_root_files(root: Path) -> list[Path]:
|
||
return [
|
||
root / "README.md",
|
||
root / "STATUS.md",
|
||
root / "技术侧需求分析.md",
|
||
root / "source" / "README.md",
|
||
root / "code" / "MANIFEST.md",
|
||
]
|
||
|
||
|
||
def task_directories(root: Path) -> list[Path]:
|
||
tasks_root = root / "tasks"
|
||
if not tasks_root.is_dir():
|
||
return []
|
||
return sorted(path for path in tasks_root.iterdir() if path.is_dir())
|
||
|
||
|
||
def required_task_names(phase: str) -> list[str]:
|
||
names = ["01.需求分析.md"]
|
||
if phase in {"g2", "g3", "archive"}:
|
||
names += ["02.技术实现方案.md", "03.冒烟与逻辑验证.md"]
|
||
if phase in {"g3", "archive"}:
|
||
names += ["04.技术实现记录.md", "05.代码Review报告.md"]
|
||
if phase == "archive":
|
||
names += ["06.验收与交付报告.md"]
|
||
return names
|
||
|
||
|
||
def check_traceability(task: Path, report: Report) -> None:
|
||
requirement = read_text(task / "01.需求分析.md", report)
|
||
design = read_text(task / "02.技术实现方案.md", report)
|
||
validation = read_text(task / "03.冒烟与逻辑验证.md", report)
|
||
required_ids = set(FUNCTION_ID.findall(requirement))
|
||
if not required_ids:
|
||
report.warning(f"未在需求分析中发现功能编号:{task}")
|
||
return
|
||
for function_id in sorted(required_ids):
|
||
if function_id not in design:
|
||
report.error(f"{task.name} 的 {function_id} 未出现在技术方案中")
|
||
if function_id not in validation:
|
||
report.error(f"{task.name} 的 {function_id} 未出现在验证文档中")
|
||
|
||
|
||
def validate_l1(
|
||
root: Path,
|
||
allow_placeholders: bool,
|
||
report: Report,
|
||
) -> None:
|
||
document = root / "01.精简变更日志.md"
|
||
if not document.is_file():
|
||
report.error(f"缺少 L1 文档:{document}")
|
||
return
|
||
text = read_text(document, report)
|
||
if not allow_placeholders and PLACEHOLDER.search(text):
|
||
report.error(f"存在未填写占位符:{document}")
|
||
check_links(document, text, report)
|
||
|
||
|
||
def validate_l2(
|
||
root: Path,
|
||
phase: str,
|
||
allow_placeholders: bool,
|
||
report: Report,
|
||
) -> None:
|
||
required = required_root_files(root)
|
||
tasks = task_directories(root)
|
||
if not tasks:
|
||
report.error(f"未发现子任务目录:{root / 'tasks'}")
|
||
|
||
for task in tasks:
|
||
for name in required_task_names(phase):
|
||
required.append(task / name)
|
||
for child in task.iterdir():
|
||
if child.name in OLD_NAMES:
|
||
report.error(f"发现旧版文档名称:{child}")
|
||
|
||
if phase == "archive":
|
||
required.append(root / "归档记录.md")
|
||
|
||
existing_required: list[Path] = []
|
||
for path in required:
|
||
if not path.is_file():
|
||
report.error(f"缺少必需文件:{path}")
|
||
else:
|
||
existing_required.append(path)
|
||
|
||
status = root / "STATUS.md"
|
||
if status.is_file():
|
||
status_text = read_text(status, report)
|
||
for heading in ("原始需求版本", "确认文档及版本", "代码版本标识"):
|
||
if heading not in status_text:
|
||
report.error(f"STATUS.md 缺少门禁绑定字段:{heading}")
|
||
|
||
for path in existing_required:
|
||
text = read_text(path, report)
|
||
if not allow_placeholders and PLACEHOLDER.search(text):
|
||
report.error(f"存在未填写占位符:{path}")
|
||
check_links(path, text, report)
|
||
|
||
if phase in {"g2", "g3", "archive"}:
|
||
for task in tasks:
|
||
if all((task / name).is_file() for name in required_task_names("g2")):
|
||
check_traceability(task, report)
|
||
|
||
|
||
def build_parser() -> argparse.ArgumentParser:
|
||
parser = argparse.ArgumentParser(
|
||
description="校验 Vibe Coding 的 L1/L2 文档完整性。",
|
||
)
|
||
parser.add_argument("path", help="L1 所在目录或 L2 PM 目录")
|
||
parser.add_argument(
|
||
"--phase",
|
||
choices=["l1", "structure", "g1", "g2", "g3", "archive"],
|
||
required=True,
|
||
help="目标校验阶段",
|
||
)
|
||
parser.add_argument(
|
||
"--allow-placeholders",
|
||
action="store_true",
|
||
help="允许模板占位符,适合刚初始化后的结构检查",
|
||
)
|
||
return parser
|
||
|
||
|
||
def main() -> int:
|
||
args = build_parser().parse_args()
|
||
root = Path(args.path).resolve()
|
||
if not root.is_dir():
|
||
print(f"校验失败:目录不存在:{root}", file=sys.stderr)
|
||
return 1
|
||
|
||
report = Report()
|
||
if args.phase == "l1":
|
||
validate_l1(root, args.allow_placeholders, report)
|
||
else:
|
||
validate_l2(root, args.phase, args.allow_placeholders, report)
|
||
|
||
for warning in report.warnings:
|
||
print(f"警告:{warning}")
|
||
for error in report.errors:
|
||
print(f"错误:{error}", file=sys.stderr)
|
||
|
||
if report.errors:
|
||
print(f"校验失败:{len(report.errors)} 个错误,{len(report.warnings)} 个警告")
|
||
return 1
|
||
print(f"校验通过:0 个错误,{len(report.warnings)} 个警告")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|