#!/usr/bin/env python3 """kb-lint-fm.py —— frontmatter 校验器 单一真相源:读 meta/kb-contract.yaml 判定 type 白名单 / 必填矩阵 / enum, 绝不硬编码 type 或字段清单。 用法: python3 tools/kb-lint-fm.py [路径...] # 默认扫全库 python3 tools/kb-lint-fm.py projects/foo # 只扫某目录/文件 退出码: 0 = 全部通过 1 = 有错误(缺硬必填 / 未激活 type / enum 非法 / 派生必填缺失) 2 = 运行环境问题(缺 PyYAML / 缺契约文件) 供 CC 判定:非 0 即需处理。 """ import sys import os import re import fnmatch try: import yaml except ImportError: print("ERROR: 需要 PyYAML。请运行: pip install pyyaml", file=sys.stderr) sys.exit(2) ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) CONTRACT_PATH = os.path.join(ROOT, "meta", "kb-contract.yaml") FM_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL) def load_contract(): if not os.path.isfile(CONTRACT_PATH): print(f"ERROR: 缺契约文件 {CONTRACT_PATH}", file=sys.stderr) sys.exit(2) with open(CONTRACT_PATH, encoding="utf-8") as f: return yaml.safe_load(f) def is_exempt(rel, patterns): for pat in patterns: if fnmatch.fnmatch(rel, pat) or fnmatch.fnmatch(rel, pat.rstrip("/") + "/*"): return True # 目录型 glob(docs/**)也匹配 docs/ 下任意层 for pat in patterns: if pat.endswith("/**") and rel.startswith(pat[:-2]): return True return False def parse_frontmatter(text): m = FM_RE.match(text) if not m: return None, "缺 frontmatter 块(--- 开头)" try: data = yaml.safe_load(m.group(1)) except yaml.YAMLError as e: return None, f"frontmatter YAML 解析失败: {e}" if not isinstance(data, dict): return None, "frontmatter 不是键值映射" return data, None def collect_md(paths): files = [] for p in paths: ap = os.path.abspath(p) if os.path.isfile(ap) and ap.endswith(".md"): files.append(ap) elif os.path.isdir(ap): for dp, _, fns in os.walk(ap): for fn in fns: if fn.endswith(".md"): files.append(os.path.join(dp, fn)) return sorted(set(files)) def check_derived(data, rules): """返回派生必填字段名列表""" req = [] t = data.get("type") src = data.get("source") for rule in rules or []: w = rule.get("when", {}) ok = True if "type_in" in w and t not in w["type_in"]: ok = False if "source_in" in w and src not in w["source_in"]: ok = False if ok: req.extend(rule.get("require", [])) return req def main(): contract = load_contract() types = {t["name"]: t for t in contract.get("types", [])} active = {n for n, t in types.items() if t.get("active")} fields = contract.get("fields", {}) enums = contract.get("enums", {}) exempt = contract.get("frontmatter_exempt", []) source_exempt_types = set(contract.get("source_exempt_types", [])) derived = contract.get("derived_rules", []) always_required = [f for f, spec in fields.items() if spec.get("required") == "always"] targets = sys.argv[1:] or [ROOT] files = collect_md(targets) errors = 0 warnings = 0 checked = 0 for fp in files: rel = os.path.relpath(fp, ROOT) if is_exempt(rel, exempt): continue checked += 1 with open(fp, encoding="utf-8") as f: text = f.read() data, err = parse_frontmatter(text) if err: print(f"[ERROR] {rel}: {err}") errors += 1 continue # type 硬必填 + 白名单 t = data.get("type") if not t: print(f"[ERROR] {rel}: 缺硬必填字段 type") errors += 1 continue if t not in types: print(f"[ERROR] {rel}: 未知 type '{t}'(不在契约中,扩展需改 kb-contract.yaml)") errors += 1 elif t not in active: print(f"[ERROR] {rel}: type '{t}' 已登记但未激活(改契约 active: true 再用)") errors += 1 # 其余硬必填 for f_ in always_required: if f_ == "type": continue if data.get(f_) in (None, ""): print(f"[ERROR] {rel}: 缺硬必填字段 {f_}") errors += 1 # 派生必填(project 型豁免溯源) req = check_derived(data, derived) if t in source_exempt_types: req = [r for r in req if r not in ("source", "source_link")] for f_ in req: if data.get(f_) in (None, ""): print(f"[ERROR] {rel}: 派生必填缺失 {f_}(type={t} + source 条件触发)") errors += 1 # enum 校验 for f_, allowed in enums.items(): v = data.get(f_) if v is not None and v not in allowed: print(f"[ERROR] {rel}: 字段 {f_}='{v}' 不在 enum {allowed}") errors += 1 # 软提示:建议但非必填的 title if not data.get("title"): print(f"[WARN] {rel}: 建议补 title(AI 可自动=H1 或文件名)") warnings += 1 print(f"\n检查 {checked} 页 | 错误 {errors} | 警告 {warnings}") sys.exit(1 if errors else 0) if __name__ == "__main__": main()