#!/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()