- 完整的知识库框架(P0-P5 路线图) - 12篇治理文档 - 工具脚本(kb-init.sh, kb-lint-fm.py, feishu-sync.py, kb-bot) - 7个 Claude 命令 - 示例项目和测试项目(wanniu-l1) - 契约文件(meta/kb-contract.yaml) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
179 lines
5.5 KiB
Python
Executable File
179 lines
5.5 KiB
Python
Executable File
#!/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()
|