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>
This commit is contained in:
178
tools/kb-lint-fm.py
Executable file
178
tools/kb-lint-fm.py
Executable file
@@ -0,0 +1,178 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user