SKILL.md claimed its appendix was spliced verbatim from templates/roles/ by a generation script (see CHANGELOG), but the pack contained none of the three. Add the nine role sources (mechanically split from the v0.2 appendix; SKILL.md untouched, zero semantic change), tools/check_appendix.py (verify + --emit, stdlib only, never edits SKILL.md), CHANGELOG.md, and test_skill_appendix_provenance.py (5 tests). Seeding unchanged: skill_seed copies SKILL.md only, so templates/tools/CHANGELOG never reach the tenant home.
234 lines
9.5 KiB
Python
234 lines
9.5 KiB
Python
#!/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/<id>.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 nine
|
||
chapters 00..08, in order, each with a source note naming its own
|
||
templates/roles/<id>.md file.
|
||
2. templates/roles/ contains exactly those nine 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 ("## 附录角色:<id>(来源
|
||
templates/roles/<id>.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 only. Chapters
|
||
00/04/07 end with their GitHub subsection and run straight into
|
||
the next heading with no separator, and 08 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"]
|
||
|
||
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 "<end of source>"
|
||
a = act_lines[i] if i < len(act_lines) else "<end of appendix>"
|
||
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())
|