#!/usr/bin/env python3 """Verify the software-engineering skill appendix against its sources. Provenance (see CHANGELOG.md in the skill directory): SKILL.md claims its appendix is "spliced verbatim from templates/roles/ by a generation script". The sources live at templates/roles/.md next to SKILL.md; this script is the check that makes that claim testable. It never edits SKILL.md: the fix for a mismatch is an editorial decision (edit the source, then re-issue the appendix through the skill's own track), never a silent rewrite by this script. What it checks, in order: 1. SKILL.md contains the appendix intro and exactly the twelve chapters 00..11, in order, each with a source note naming its own templates/roles/.md file. 2. templates/roles/ contains exactly those twelve source files: a missing source fails, an unexpected extra file fails. 3. Each source, in canonical form, equals the matching appendix chapter in canonical form (see canonical() below). 4. CHANGELOG.md exists in the skill directory and mentions templates/roles/, so the "see CHANGELOG" pointer in SKILL.md resolves to a provenance record, not a dangling reference. Canonical form (the ONLY allowed differences between a source file and its appendix chapter -- anything else is a failure): a. The chapter heading line itself ("## 附录角色:(来源 templates/roles/.md)") exists only in SKILL.md; it is derived from the file name, not stored in the source. b. One inter-chapter separator line "---": in the current SKILL.md it appears after chapters 01/02/03/05/06/09 only. Chapters 00/04/07 end with their GitHub subsection and run straight into the next heading with no separator, and 11-auditor is the end of file. The check strips at most one trailing separator; it does NOT normalise SKILL.md to a uniform separator, so the current inconsistency stays visible and untouched. c. Blank lines around the heading and at the chapter end: a source file is stored with exactly one trailing newline and no leading blank line; the check strips surrounding blank lines before comparing. Any other difference -- wording, punctuation, order, a dropped or added line -- fails with the first differing line number. Usage: python3 tools/check_appendix.py [--skill-dir DIR] python3 tools/check_appendix.py --emit [--skill-dir DIR] --emit prints the spliced appendix in canonical form (heading line + source body per chapter) to stdout. It is a drafting aid for an editor, NOT a byte patch for SKILL.md: because of (b) and (c) above, its output differs from SKILL.md in separators and blank lines only, and it must never be pasted over SKILL.md without a review pass. Exit codes: 0 = all chapters match; 1 = any check failed; 2 = usage error (argparse). Only the standard library is used, so the script runs standalone without importing cloud/. """ import argparse import re import sys from pathlib import Path EXPECTED_IDS = ["00-coordinator", "01-product-manager", "02-architect", "03-designer", "04-client-developer", "05-backend", "06-qa", "07-acceptance", "08-shared-core", "09-ops", "10-iteration", "11-auditor"] CHAPTER_RE = re.compile( r"^## 附录角色:(\d{2}-[a-z0-9-]+)" r"(来源 templates/roles/(\d{2}-[a-z0-9-]+\.md))", flags=re.M) TITLE_RE = re.compile(r"^# 角色模板:", flags=re.M) APPENDIX_MARKER = "## 附录:角色模板" def canonical(segment): """Canonical chapter body: strip surrounding blanks and at most one trailing inter-chapter separator line, end with one newline.""" lines = segment.split("\n") while lines and lines[0] == "": lines.pop(0) while lines and lines[-1] == "": lines.pop() if lines and lines[-1] == "---": lines.pop() while lines and lines[-1] == "": lines.pop() return "\n".join(lines) + "\n" def first_diff(expected, actual): """Return (line_no_1_based, expected_line, actual_line) or None.""" exp_lines = expected.split("\n") act_lines = actual.split("\n") for i, (e, a) in enumerate(zip(exp_lines, act_lines)): if e != a: return i + 1, e, a if len(exp_lines) != len(act_lines): i = min(len(exp_lines), len(act_lines)) e = exp_lines[i] if i < len(exp_lines) else "" a = act_lines[i] if i < len(act_lines) else "" return i + 1, e, a return None def load_chapters(skill_dir): """Return (errors, chapters) where chapters maps id -> body segment (raw text between its heading line and the next heading / EOF).""" errors = [] skill_path = skill_dir / "SKILL.md" if not skill_path.is_file(): return ["SKILL.md 缺失:%s" % skill_path], {} text = skill_path.read_text(encoding="utf-8") if APPENDIX_MARKER not in text: return ["SKILL.md 缺失附录引言节 %r" % APPENDIX_MARKER], {} appendix = text.split(APPENDIX_MARKER, 1)[1] matches = list(CHAPTER_RE.finditer(appendix)) found = [m.group(1) for m in matches] if found != EXPECTED_IDS: errors.append( "附录章节不符:应为 %s,实际为 %s" % (EXPECTED_IDS, found)) chapters = {} for i, m in enumerate(matches): end = matches[i + 1].start() if i + 1 < len(matches) else len(appendix) if m.group(2) != m.group(1) + ".md": errors.append( "章节 %s 的来源注不符:应为 %s,实际为 %s" % (m.group(1), m.group(1) + ".md", m.group(2))) chapters[m.group(1)] = appendix[m.end():end] return errors, chapters def check(skill_dir): """Return a list of failure strings (empty means pass).""" errors, chapters = load_chapters(skill_dir) roles_dir = skill_dir / "templates" / "roles" if not roles_dir.is_dir(): errors.append("源目录缺失:%s" % roles_dir) sources = {} else: on_disk = sorted(p.name for p in roles_dir.iterdir() if p.is_file()) expected_files = [cid + ".md" for cid in EXPECTED_IDS] if on_disk != expected_files: errors.append( "源文件集合不符:应为 %s,实际为 %s" % (expected_files, on_disk)) sources = {} for name in on_disk: sources[name[:-3]] = (roles_dir / name).read_text( encoding="utf-8") for cid in EXPECTED_IDS: if cid not in sources: errors.append("源缺失:templates/roles/%s.md 不存在" % cid) continue source = sources[cid] if not TITLE_RE.match(source): errors.append( "源格式不符:%s.md 首行不是“# 角色模板:”" % cid) if canonical(source) != source: errors.append( "源非规范形式:%s.md 含首尾空行或尾部分隔符" "(源文件须以正文起、以单个换行收)" % cid) if cid not in chapters: errors.append("附录缺失章节:%s" % cid) continue expected = source actual = canonical(chapters[cid]) if actual != expected: diff = first_diff(expected, actual) if diff is not None: lineno, exp_line, act_line = diff errors.append( "内容不一致:章节 %s 第 %d 行不同\n" " 源:%s\n 附录:%s" % (cid, lineno, exp_line[:120], act_line[:120])) else: errors.append("内容不一致:章节 %s(未定位首异行)" % cid) changelog = skill_dir / "CHANGELOG.md" if not changelog.is_file(): errors.append("CHANGELOG.md 缺失:%s" % changelog) elif "templates/roles/" not in changelog.read_text(encoding="utf-8"): errors.append( "CHANGELOG.md 未提及 templates/roles/,溯源记录不完整") return errors def emit(skill_dir): """Print the spliced appendix in canonical form to stdout.""" _errors, chapters = load_chapters(skill_dir) roles_dir = skill_dir / "templates" / "roles" parts = [] for cid in EXPECTED_IDS: source = (roles_dir / (cid + ".md")).read_text(encoding="utf-8") heading = ("## 附录角色:%s(来源 templates/roles/%s.md)" % (cid, cid)) parts.append(heading + "\n\n" + source.rstrip("\n")) return "\n\n".join(parts) + "\n" def main(argv=None): parser = argparse.ArgumentParser( description="Verify the software-engineering skill appendix " "against templates/roles/ sources.") parser.add_argument( "--skill-dir", default=None, help="skill directory (default: parent of this script's " "tools/ directory)") parser.add_argument( "--emit", action="store_true", help="print the spliced appendix in canonical form instead " "of verifying (drafting aid only, never a byte patch)") args = parser.parse_args(argv) if args.skill_dir: skill_dir = Path(args.skill_dir) else: skill_dir = Path(__file__).resolve().parents[1] if args.emit: sys.stdout.write(emit(skill_dir)) return 0 errors = check(skill_dir) if errors: for err in errors: print("FAIL: " + err, file=sys.stderr) print("appendix provenance FAILED: %d problem(s)" % len(errors), file=sys.stderr) return 1 print("appendix provenance OK: %d chapters match templates/roles/" % len(EXPECTED_IDS)) return 0 if __name__ == "__main__": raise SystemExit(main())