Files
workbuddy_skills/session-mechanism/scripts/check_resource_index.py
T
admin acfb252bee feat(skills): 两包资源索引机制与双向对账脚本
- product-planning:新建 references/manifest.md 作资源唯一登记处(资源/落点/引用点/口径);
  顶层 SKILL 加两条硬规矩,design-capture、stage-delivery、execution-runbook、stage-requirements
  四处把散落路径改为「落点见 references/manifest.md」
- session-mechanism:01-文档索引 新增 §四「包内资源索引」,并说明不放 manifest.md 的理由
  (那份由 install.py 本机生成、被 .gitignore 挡着)
- 新增两包共用的 scripts/check_resource_index.py(双向对账)与 product-planning/scripts/check_figures.py(图片机检),
  selftest 补对应用例,06-技能优化规程 写入写法规矩
- .gitignore:新增 _留痕/ 不入库(改造前整包快照,180K/688K,库内从无先例)
2026-10-18 18:07:40 +08:00

321 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""资源索引 · 双向对账机检(技能包内通用)
用途
----
每个技能包在 `references/manifest.md` 里维护一张「资源索引」表:
| 资源 | 落点 | 干什么 | 被谁引用 | 许可字面量 | 口径 | 入仓 |
本脚本对这张表做**双向对账**,四个闸门:
G1 落点存在 —— 每行 `落点` 必须在磁盘上(`入仓=否` 的行本机缺只给 ⚠,属正常)
G2 引用为真 —— `被谁引用` 里登记的每个文件必须存在、且真的提到该资源(⛔ 不许登记假引用点;
`许可字面量` 指向的文件也必须存在,⛔ 不许开空头许可)
G3 字面量收敛 —— `口径=只写一次` 的行:该资源的路径字面量只许出现在清单本身与 `许可字面量` 登记的文件里,
出现在别处 ⇒ 🔴。这条就是「避免资源到处引用」的机检(⛔ 漏一处必报,不静默放行)
G4 孤儿 / 未登记 —— `被谁引用` 为空的行 ⇒ 🔴;`assets/` 与 `references/stage-*/assets/` 下
磁盘上有一级子项而表里没有 ⇒ 🔴
🔴 `被谁引用` 与 `许可字面量` 是**两件事,别合并**:前者是「谁在用」(可由 `--sync-refs` 自动回写),
后者是「谁被允许写路径字面量」(人工开的口子,⛔ 自动回写会把它冲成"谁都许可"⇒ G3 变空闸门)。
「被谁引用」怎么认:既认**路径形态**(含该资源路径尾两段),也认**名字形态**(含资源名或路径末段)
—— 因为收敛之后散文里就只剩名字了,只认路径会把收敛好的资源误判成孤儿。
用法
----
python check_resource_index.py --pkg <技能包根>
python check_resource_index.py --pkg <技能包根> --index references/01-文档索引.md
python check_resource_index.py --pkg <技能包根> --sync-refs # 用扫描结果回写「被谁引用」列
🔴 索引文件必须放在该技能包**入库(git 跟踪)**的那份文件里 —— 实测踩过:
`session-mechanism/references/manifest.md` 被 `.gitignore` 挡着(它是 `install.py --manifest`
在本机生成、随机器变)⇒ 索引写那里=别台机器取仓后**看不到**,机制静默失效。
该包的索引改放 `references/01-文档索引.md`(入库),用 `--index` 指过去。
退出码:0 全过 | 1 有 🔴 | 2 用法或清单结构错(找不到表头 ⇒ 停手,⛔ 不猜)
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
try: # 出口一律声明编码(本机铁律)
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
HEADER = "| 资源 |"
COLS = ["资源", "落点", "干什么", "被谁引用", "许可字面量", "口径", "入仓"]
COLLAPSE = "只写一次" # 口径取值:只写一次 / 可多次
SYNC_COL = 3 # 「被谁引用」在第几列(0 起)
SYNC_CAP = 12 # 回写「被谁引用」时最多列几个文件,其余只记个数
TEXT_EXT = {".md", ".py", ".js", ".mjs", ".cjs", ".json", ".html", ".htm",
".css", ".sh", ".bash", ".tpl", ".txt", ".yaml", ".yml"}
SKIP_DIR = {"_留痕", "__pycache__", "logs", "node_modules", ".git", "env",
".obsidian", "marks"}
SKIP_FILE = {"install.log"}
def is_scannable(p: Path, pkg: Path) -> bool:
rel = p.relative_to(pkg)
if any(part in SKIP_DIR for part in rel.parts):
return False
if p.name in SKIP_FILE or p.suffix not in TEXT_EXT:
return False
return True
def scan_files(pkg: Path) -> list[Path]:
out = [p for p in pkg.rglob("*") if p.is_file() and is_scannable(p, pkg)]
return sorted(out, key=lambda q: q.relative_to(pkg).as_posix())
def read_text(p: Path) -> str:
try:
return p.read_text(encoding="utf-8", errors="replace")
except Exception:
return ""
def cell(s: str) -> str:
return s.strip().strip("`").strip()
def split_refs(raw: str) -> list[str]:
"""`被谁引用` / `许可字面量` 列 → 条目清单(按 `、;;,` 分隔)。
🔴 原样保留条目文本,⛔ 不在这一步剥括号 —— 真实文件名里可能就带全角括号
(实测踩过:`references/作业规矩/00-作业总规矩(原 agent-operating-rules).md` 被剥成
`…/00-作业总规矩.md` ⇒ 不存在 ⇒ 报假 🔴)。括号交给 `resolve()` 按「原样 → 去括号」两候选试。
"""
if not raw or raw.strip() in ("—", "-", "(无)", "(无)"):
return []
out: list[str] = []
for it in re.split(r"[;;、,,]", raw):
s = cell(it)
if s:
out.append(s)
return out
def resolve(pkg: Path, item: str) -> Path | None:
"""把一条登记项解析成真实路径:先按原样,再按「剥掉括号说明」的样子各试一次。"""
if item.startswith("<技能根>/"):
cands = [same_root_dir(pkg, item)]
else:
cands = [pkg / item, pkg / re.sub(r"([^)]*)", "", item).strip(),
pkg / re.sub(r"\([^)]*\)", "", item).strip()]
for c in cands:
if c.exists():
return c
return None
class Row:
def __init__(self, raw: str):
c = raw.strip().strip("|").split("|")
c += [""] * (len(COLS) - len(c))
self.name = cell(c[0])
self.loc = cell(c[1])
self.what = cell(c[2])
self.refs_raw = c[3].strip()
self.allow_raw = c[4].strip() # 许可字面量:允许写该资源路径字面量的文件(G3 用)
self.mode = cell(c[5])
self.inrepo = cell(c[6])
segs = [s for s in self.loc.replace("<技能根>/", "").split("/") if s]
self.leaf = segs[-1] if segs else self.loc # 路径末段
self.key = "/".join(segs[-2:]) if len(segs) >= 2 else self.leaf # 路径尾两段
if self.loc.startswith("<技能根>/") and len(segs) == 1:
self.key = segs[0]
def load_table(man: Path) -> tuple[list[Row], int]:
lines = man.read_text(encoding="utf-8").replace("\r\n", "\n").split("\n")
head = next((i for i, ln in enumerate(lines) if ln.startswith(HEADER)), None)
if head is None:
raise SystemExit(f"🔴 {man} 里找不到 `{HEADER}` 表头 ⇒ 停手(⛔ 不猜、不追加)")
rows: list[Row] = []
i = head + 2
while i < len(lines) and lines[i].startswith("|"):
if cell(lines[i].strip().strip("|").split("|")[0]):
rows.append(Row(lines[i]))
i += 1
if not rows:
raise SystemExit(f"🔴 {man} 的表里没有任何数据行 ⇒ 停手")
return rows, head
def path_hits(r: Row, pkg: Path, files: list[Path], man: Path) -> list[tuple[Path, int, str]]:
"""G3 用:找**路径形态**(含资源路径尾两段字面量)的出现。"""
pat = re.compile(re.escape(r.key))
out: list[tuple[Path, int, str]] = []
for f in files:
if f == man:
continue
for n, ln in enumerate(read_text(f).split("\n"), 1):
if r.key in ln and pat.search(ln):
out.append((f, n, ln.strip()[:120]))
return out
def ref_hits(r: Row, pkg: Path, files: list[Path], man: Path) -> list[str]:
"""「被谁引用」用:路径形态 或 名字形态(资源名 / 路径末段)都算。"""
toks = {t for t in (r.key, r.leaf, r.name) if t}
hits: list[str] = []
for f in files:
if f == man:
continue
txt = read_text(f)
if any(t in txt for t in toks):
hits.append(f.relative_to(pkg).as_posix())
return hits
def same_root_dir(pkg: Path, loc: str) -> Path:
name = loc.replace("<技能根>/", "").strip("/").split("/")[0]
return pkg.parent / name
def main() -> int:
ap = argparse.ArgumentParser(description="资源索引 · 双向对账机检")
ap.add_argument("--pkg", required=True, help="技能包根(如 E:/.../skills/product-planning)")
ap.add_argument("--index", default="references/manifest.md",
help="资源索引表所在文件(默认 references/manifest.md;"
"某包的清单若不入库,就把索引放进该包**入库**的那份索引文件,再指过来)")
ap.add_argument("--sync-refs", action="store_true",
help="用扫描结果回写「被谁引用」列(逗号分隔的文件清单)")
ap.add_argument("--max-list", type=int, default=10, help="每个闸门最多列几条明细")
args = ap.parse_args()
pkg = Path(args.pkg).expanduser().resolve()
if not pkg.is_dir():
print(f"🔴 技能包根不存在:{pkg}")
return 2
man = pkg / args.index
if not man.is_file():
print(f"🔴 找不到资源索引文件 {man}(用 --index 指定;默认 references/manifest.md)")
return 2
rows, head = load_table(man)
files = scan_files(pkg)
print(f"═══ 资源索引对账 · {pkg.name} ═══")
print(f"清单:{man.relative_to(pkg).as_posix()} | 表行:{len(rows)} | 扫描文件:{len(files)}\n")
g1: list[str] = []
g2: list[str] = []
g3: list[str] = []
g4: list[str] = []
computed: dict[str, list[str]] = {}
for r in rows:
# ── G1 落点存在 ────────────────────────────────────────────────
if r.loc.startswith("<技能根>/"):
cand = same_root_dir(pkg, r.loc)
if not cand.is_dir() and r.inrepo != "否":
g1.append(f"「{r.name}」落点(同根技能)不存在:{cand}")
else:
cand = pkg / r.loc.rstrip("/")
if not cand.exists():
if r.inrepo == "否":
print(f" ⚠ 「{r.name}」落点本机缺(入仓=否 ⇒ 正常,新机器取仓后这一层为空):{r.loc}")
else:
g1.append(f"「{r.name}」落点不存在:{r.loc}")
# ── 引用扫描(G2 / G3 / --sync-refs 共用)──────────────────────
computed[r.name] = ref_hits(r, pkg, files, man)
declared = split_refs(r.refs_raw)
# ── G2 引用为真 ────────────────────────────────────────────────
for d in declared:
dp = resolve(pkg, d)
if dp is None:
g2.append(f"「{r.name}」登记的引用点不存在:{d}")
elif not dp.is_file():
pass # 同根技能目录:只判存在
elif r.name not in read_text(dp) and r.leaf not in read_text(dp) \
and r.key not in read_text(dp):
g2.append(f"「{r.name}」登记的引用点其实没提到它:{d}")
# ── G3 字面量收敛(仅 `只写一次`)──────────────────────────────
if r.mode == COLLAPSE:
allow: set[str] = set()
for d in split_refs(r.allow_raw):
if resolve(pkg, d) is None:
g2.append(f"「{r.name}」`许可字面量` 指向的文件不存在:{d}") # ⛔ 不许开空头许可
allow.add(d.replace("\\", "/"))
for f, n, t in path_hits(r, pkg, files, man):
rel = f.relative_to(pkg).as_posix()
if rel in allow:
continue
g3.append(f"「{r.name}」字面量出现在未登记处:{rel}:{n} ⇒ {t}")
# ── G4 另一半:孤儿登记 + 磁盘上有而表里没有 ────────────────────
for r in rows:
if not split_refs(r.refs_raw):
g4.append(f"「{r.name}」`被谁引用` 为空 ⇒ 没人引用的登记项")
registered = {r.loc.rstrip("/") for r in rows}
for d in [pkg / "assets"] + sorted(pkg.glob("references/stage-*/assets")):
if not d.is_dir():
continue
for sub in sorted(d.iterdir()):
if sub.name.startswith(".") or sub.name.startswith("_"):
continue
rel = sub.relative_to(pkg).as_posix().rstrip("/")
if not any(rl.endswith(rel) or rel.endswith(rl) for rl in registered):
g4.append(f"磁盘上有、清单里没有:{rel}")
# ── 汇总 ────────────────────────────────────────────────────────────
gates = [("G1 落点存在", g1), ("G2 引用为真", g2),
("G3 字面量收敛", g3), ("G4 孤儿 / 未登记", g4)]
bad = 0
for title, items in gates:
if items:
bad += 1
print(f"🔴 {title}:{len(items)} 处")
for x in items[: args.max_list]:
print(f" · {x}")
if len(items) > args.max_list:
print(f" … 余 {len(items) - args.max_list} 处")
else:
print(f"✅ {title}:0")
if args.sync_refs:
raw = man.read_bytes()
crlf = b"\r\n" in raw
lines = raw.decode("utf-8").replace("\r\n", "\n").split("\n")
for i in range(head + 2, len(lines)):
if not lines[i].startswith("|"):
break
c = lines[i].strip().strip("|").split("|")
if len(c) < len(COLS) or not cell(c[0]):
continue
lst = computed.get(cell(c[0]), [])
shown = lst[:SYNC_CAP]
txt = "、".join(shown)
if len(lst) > SYNC_CAP:
txt += f"(共 {len(lst)} 个)" # 括号里的计数会被 split_refs 剥掉,不影响 G2 校验
c[3] = " " + (txt if txt else "—") + " "
lines[i] = "|" + "|".join(c) + "|"
out = "\n".join(lines)
man.write_bytes((out.replace("\n", "\r\n") if crlf else out).encode("utf-8"))
print(f"\n✍️ 已用扫描结果回写「被谁引用」列:{man.name}")
print()
if bad:
print(f"结论:🔴 不通过 —— {bad} 个闸门有命中(⛔ 漏一处必报,不静默放行)")
return 1
print("结论:✅ 通过 —— 四个闸门全 0")
return 0
if __name__ == "__main__":
raise SystemExit(main())