159 lines
7.4 KiB
Python
159 lines
7.4 KiB
Python
#!/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())
|