#!/usr/bin/env python3 """kb-objects-audit — еженедельный health-check vault'а. Проверки: 1. Каждый онлайн-netbird-пир имеет привязку к проекту (через aliases в frontmatter или собственную карточку). Orphans = клиентские машины без описания. 2. Каждый projects//README.md имеет валидный frontmatter (type, status, aliases как минимум). 3. Битые wiki-ссылки `[[...]]` в vault'е (указывают в небытие). 4. Дубли по нечёткому совпадению заголовков (опасные близнецы). Output: audit/YYYY-MM-DD-objects-audit.md (каждый запуск перезаписывает дневной). Запуск ручной или cron weekly: cd ~/knowledge-base && python3 scripts/kb-objects-audit.py """ from datetime import date from pathlib import Path import json import re import sys VAULT = Path(__file__).resolve().parent.parent OBJECTS_MAP = VAULT / "audit/objects-map.json" REQUIRED_FRONTMATTER_KEYS = ["type", "status"] PROJECT_FRONTMATTER_KEYS = ["type", "status", "aliases"] def load_map() -> list: if not OBJECTS_MAP.exists(): sys.exit(f"FATAL: нет {OBJECTS_MAP.relative_to(VAULT)} — запусти scripts/kb-objects-map.py сначала") return json.loads(OBJECTS_MAP.read_text()) def parse_fm(text: str) -> dict: m = re.match(r"^---\n(.+?)\n---\n", text, re.S) if not m: return {} fm = {} for line in m.group(1).splitlines(): if ":" not in line or line.startswith("#"): continue k, _, v = line.partition(":") fm[k.strip()] = v.strip() return fm def find_md_files() -> list[Path]: out = [] for p in VAULT.rglob("*.md"): rel = p.relative_to(VAULT) if any(part.startswith(".") for part in rel.parts): continue if rel.parts[0] in ("audit",): # audit-отчёты сами не аудируются if "archive" in rel.parts: continue out.append(p) return out def check_project_frontmatter(objects: list) -> list[str]: issues = [] for o in objects: if o["type"] != "project" or not o["file"]: continue path = VAULT / o["file"] if not path.exists(): issues.append(f"- `{o['id']}`: file missing — `{o['file']}`") continue fm = parse_fm(path.read_text()) missing = [k for k in PROJECT_FRONTMATTER_KEYS if k not in fm] if missing: issues.append(f"- `{o['id']}`: frontmatter missing {missing} — `{o['file']}`") return issues def check_broken_wikilinks(files: list[Path]) -> list[tuple[str, str, str]]: """Возвращает [(source, link, reason)] для битых [[...]] ссылок. Проверка только полноценных wiki-ссылок [[...]] (двойная скобка с обеих сторон).""" issues = [] all_basenames = {f.stem for f in files} all_relpaths = {str(f.relative_to(VAULT)).replace(".md", "") for f in files} # match [[target]] / [[target|alias]] / [[target#anchor]] pat = re.compile(r"\[\[([^\]\|#\n]+?)(?:[\|#][^\]\n]*)?\]\]") for f in files: # пропускаем graphify-плагин output и весь audit/ (само-цитирование, autogen) rel = f.relative_to(VAULT) if "graphify-out" in rel.parts: continue if rel.parts[0] == "audit": continue if str(rel) == "CLAUDE.md": continue # обучающие placeholder'ы вроде [[двойные скобки]] try: text = f.read_text() except Exception: continue for m in pat.finditer(text): target = m.group(1).strip().rstrip("/").removesuffix(".md") if not target or target in (".", ".."): continue # Allow absolute by relpath, basename, or relative-to-source if target in all_relpaths: continue base = target.split("/")[-1] if base in all_basenames: continue src_dir = f.parent.relative_to(VAULT) resolved = str(src_dir / target).replace("./", "") if resolved in all_relpaths: continue issues.append((str(rel), m.group(0), "→ нет такого файла")) return issues def check_orphans(objects: list) -> list[dict]: return [o for o in objects if o["type"] == "netbird-only" and o["netbird_peers"] and o["netbird_peers"][0].get("online", True)] def main() -> None: objects = load_map() files = find_md_files() fm_issues = check_project_frontmatter(objects) orphans = check_orphans(objects) wiki_issues = check_broken_wikilinks(files) # счётчики n_proj = sum(1 for o in objects if o["type"] == "project") n_proj_with_fm = n_proj - len(fm_issues) n_orphan_online = len(orphans) n_wiki_broken = len(wiki_issues) score = len(fm_issues) * 5 + n_orphan_online * 2 + n_wiki_broken * 3 today = date.today().isoformat() out = VAULT / f"audit/{today}-objects-audit.md" md = [ "---", f"date: {today}", "type: audit", "source: scripts/kb-objects-audit.py", "tags: [audit, objects, frontmatter, links]", f"score: {score}", "---", "", f"# KB objects audit — {today}", "", f"**Score (меньше = лучше): `{score}`**", "", f"- Проектов с frontmatter: **{n_proj_with_fm}/{n_proj}** ({len(fm_issues)} проблем)", f"- NetBird online-пиров без проектной карточки: **{n_orphan_online}**", f"- Битых wiki-ссылок `[[...]]`: **{n_wiki_broken}**", "", ] md.extend(["## Frontmatter в projects/", ""]) if fm_issues: md.extend(fm_issues) else: md.append("✅ все проекты имеют валидный frontmatter") md.append("") md.extend(["## Online netbird-пиры без проектной карточки", "", "Эти пиры онлайн в NetBird, но не привязаны ни к одной projects/-странице. ", "Бот не сможет ответить «найди X» осмысленно — нет файла или alias.", "", "Лечение: либо создать stub в `projects//README.md` (см. `projects/lipki/` как образец), ", "либо добавить имя пира как полную строку в `aliases` подходящего проекта.", "", "| NetBird-имя | IP | OS | Город |", "|---|---|---|---|"]) if orphans: for o in orphans: p = o["netbird_peers"][0] md.append(f"| `{p['name']}` | {p['ip']} | {p.get('os','')} | {p.get('city','')} |") else: md.append("| — | — | — | — |") md.append("") md.append("✅ все онлайн-пиры покрыты") md.append("") md.extend(["## Битые wiki-ссылки", ""]) if wiki_issues: for src, link, reason in wiki_issues[:50]: md.append(f"- [{src}]({src}) — `{link}` {reason}") if len(wiki_issues) > 50: md.append(f"- ... ещё {len(wiki_issues)-50} (truncated до 50)") else: md.append("✅ битых ссылок не найдено") md.append("") out.write_text("\n".join(md)) print(f"Wrote {out.relative_to(VAULT)} (score={score})") print(f" frontmatter issues: {len(fm_issues)}") print(f" orphan online peers: {n_orphan_online}") print(f" broken wiki links: {n_wiki_broken}") if __name__ == "__main__": main()