- 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,库内从无先例)
321 lines
14 KiB
Python
321 lines
14 KiB
Python
#!/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())
|