Files
company-kb/tools/kb-lint-fm.py
yangqianqian 84f7950589 Initial commit: company-kb framework
- 完整的知识库框架(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>
2026-07-16 18:24:39 +08:00

179 lines
5.5 KiB
Python
Executable File
Raw Permalink 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
"""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
# 目录型 globdocs/**)也匹配 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}: 建议补 titleAI 可自动=H1 或文件名)")
warnings += 1
print(f"\n检查 {checked} 页 | 错误 {errors} | 警告 {warnings}")
sys.exit(1 if errors else 0)
if __name__ == "__main__":
main()