docs(skill): add appendix provenance sources, checker and CHANGELOG
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.
This commit is contained in:
1 parent
61e08a1a8c
commit
4e554b5c07
12 files changed
+818
No files matched your search
@@ -0,0 +1,233 @@
|
||||
#!/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())
|
||||
Reference in new issue
Block a user