Files
workbuddy_skills/dsh-knowledge/references/dsh-knowledge-upkeep/split_skill.py
T

159 lines
7.4 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""split_skill.py — 技能重组:把千行级技能的 SKILL.md 拆成「主干 + references/ 详情档」。
用法:
<python> split_skill.py --spec <spec.json> [--dry-run]
设计(照 `dsh-knowledge-upkeep` §10):
· **纯机械搬运** —— 只按行号区间搬移,逐行未改;不重写、不润色、不删字。
· **可重复跑(幂等)** —— 永远以 `<backup_dir>/<skill>-SKILL.md` 为拆前基准;
首次运行自动生成该备份。⚠️ 绝不以「活文件」为基准(否则第二次跑会二次切割)。
· **四项自校验**:① 行数守恒 ② 逐行包含 0 丢失 ③ 节可寻址 ④ frontmatter 唯一。
spec.json:
{
"skills_dir": "E:/ProgramData/.workbuddy/skills",
"backup_dir": "…/tmp/_keep-YYYYMMDD/split-bak",
"skill": "dsh-change-workflow",
"main_desc": "§1 … · §2 …", // 写进详情档头的「主文件」说明
"moves": [
{"ranges": [[14,92]], "ref": "00-平台速查.md",
"title": "平台速查(硬编码事实,勿猜)",
"covers": ["平台速查(硬编码事实,勿猜)"], // 用于主干索引表的「覆盖的原章节」列
"pointer": "> 📂 **…已下沉** → `references/00-平台速查.md`"}
]
}
ranges = 1-based 闭区间,可多段,**不得重叠**;pointer 支持 \n 多行。
"""
import argparse
import hashlib
import json
import os
import re
import sys
from collections import Counter
HDR = ("# {title}\n\n"
"> **归属**:技能 `{skill}` 的详情档(**按需读**,不是每次都要读)。\n"
"> **本档覆盖**:{covers}(原行 {rng})。\n"
"> **主文件 / 判据与流程主干** = `../SKILL.md`({main})。\n"
"> **来源**:技能重组把 `../SKILL.md` 的 {rng} 段**逐行原样**下沉到本文件,未改一字。\n"
"> **跨档引用**:正文里的「§N / 见 §8 坑 N / 见下表」等编号,用 `../SKILL.md` 末节「详情索引」的原章节列定位。\n\n---\n")
md5 = lambda b: hashlib.md5(b).hexdigest()
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--spec", required=True)
ap.add_argument("--dry-run", action="store_true")
a = ap.parse_args()
cfg = json.load(open(a.spec, encoding="utf-8"))
skill = cfg["skill"]
d = os.path.join(cfg["skills_dir"], skill)
src = os.path.join(d, "SKILL.md")
os.makedirs(cfg["backup_dir"], exist_ok=True)
bak = os.path.join(cfg["backup_dir"], "%s-SKILL.md" % skill)
if not os.path.exists(bak): # 首次:以活文件为基准建备份
open(bak, "wb").write(open(src, "rb").read())
orig_bytes = open(bak, "rb").read() # 之后永远以备份为基准(幂等)
orig = orig_bytes.decode("utf-8").split("\n")
if orig and orig[-1] == "":
orig = orig[:-1]
n = len(orig)
# ── 区间校验
moved, starts = {}, {}
for mv in cfg["moves"]:
for rng in mv["ranges"]:
lo, hi = rng
assert 1 <= lo <= hi <= n, "区间越界 %s(原文件 %d 行)" % (rng, n)
for i in range(lo, hi + 1):
assert i not in moved, "区间重叠于第 %d 行" % i
moved[i] = mv["ref"]
starts[min(r[0] for r in mv["ranges"])] = mv
# ── 生成主干
out, ptrs = [], []
for i in range(1, n + 1):
if i in starts:
out.append(starts[i]["pointer"])
out.append("")
ptrs.append(starts[i]["pointer"])
if i not in moved:
out.append(orig[i - 1])
main_len = len(out)
# ── 生成详情档
refs = {}
for mv in cfg["moves"]:
refs.setdefault(mv["ref"], []).append(mv)
index, ref_body = [], {}
for refname, mvs in sorted(refs.items()):
rngs = sorted(r for mv in mvs for r in mv["ranges"])
body = [l for (lo, hi) in rngs for l in orig[lo - 1:hi]]
rng_txt = " + ".join("L%d–L%d" % (lo, hi) for (lo, hi) in rngs)
covers = " · ".join(dict.fromkeys(c for mv in mvs for c in mv.get("covers", [])))
head = HDR.format(title=mvs[0]["title"], skill=skill, covers=covers,
rng=rng_txt, main=cfg.get("main_desc", ""))
ref_body[refname] = body
index.append((refname, covers, len(body), rng_txt))
if not a.dry_run:
rd = os.path.join(d, "references")
os.makedirs(rd, exist_ok=True)
with open(os.path.join(rd, refname), "w", encoding="utf-8", newline="\n") as f:
f.write(head + "\n".join(body) + "\n")
# ── 主干末节:详情索引(= 跨档引用的定位表)
out += ["", "## 详情索引(references/)", "",
"> 本技能 = **主干(本文件)+ 详情档**。主干只留判据 / 流程主干 / 命令骨架;长表、案例、实测记录、历史细节在下面各档。",
">", "> **跨档引用怎么查**:正文里的「§N」「见 §8 坑 N」「见下表」等编号,按本表「覆盖的原章节」列定位。",
"", "| 详情档 | 覆盖的原章节 | 原行段 | 行数 |", "|---|---|---|---|"]
for refname, covers, ln, rng_txt in index:
out.append("| `references/%s` | %s | %s | %d |" % (refname, covers.replace("|", "/"), rng_txt, ln))
out.append("")
idx_block = len(out) - main_len
text = "\n".join(out).rstrip("\n") + "\n"
new_lines = text.split("\n")[:-1]
# ── 四项自校验
core = main_len - 2 * len(cfg["moves"])
det = sum(len(v) for v in ref_body.values())
A = Counter(orig)
B = Counter([l for l in new_lines if l not in ptrs]
+ [l for rn in ref_body for l in ref_body[rn]])
lost = [k for k in A if A[k] > B[k]]
heads = [re.sub(r"^#+\s*", "", l).strip() for l in orig if re.match(r"^#{1,3} ", l)]
blob = text + "".join("\n".join(v) for v in ref_body.values())
miss = [h for h in heads if h and h not in blob]
fm = [l for l in new_lines[:14] if l.startswith("version:")]
checks = [
("① 行数守恒", core + det == n, "主干内容 %d + 详情 %d = %d(原 %d)" % (core, det, core + det, n)),
("② 逐行包含", not lost, "丢失 %d 行;新增(指针 %d + 索引 %d)" % (len(lost), 2 * len(cfg["moves"]), idx_block)),
("③ 节可寻址", not miss, "标题 %d 个,未命中 %d%s" % (len(heads), len(miss), (" " + repr(miss[:3])) if miss else "")),
("④ frontmatter", len(fm) == 1, "version 行 = %s" % (fm or "缺失/重复")),
]
print("=" * 68)
print("技能 %s:原 %d 行 md5=%s" % (skill, n, md5(orig_bytes)))
for name, ok, info in checks:
print(" %s %s ── %s" % ("OK " if ok else "FAIL", name, info))
print(" 主干 SKILL.md = %d 行 %s" % (len(new_lines), "(dry-run,未落盘)" if a.dry_run else "md5=%s" % md5(text.encode("utf-8"))))
for refname, covers, ln, rng_txt in index:
print(" - %-40s %4d 行 %s" % (refname, ln, rng_txt))
if not a.dry_run:
with open(src, "w", encoding="utf-8", newline="\n") as f:
f.write(text)
bad = [c[0] for c in checks if not c[1]]
if bad:
print("⛔ 未通过:%s ⇒ 停下来看,别硬推" % "、".join(bad))
return 1
print("✅ 四项校验全绿" + ("(dry-run)" if a.dry_run else ";记得三处同步 + 三方 md5 对账"))
return 0
if __name__ == "__main__":
sys.exit(main())