File size: 6,261 Bytes
216004d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 | from __future__ import annotations
# llm-wiki-version: 2.0.0
# runtime: dev-only
"""
supersede_check.py — v2 supersede/contradicts chain validator.
Rohit G00 v2 范式:每个 wiki 页可以 supersede 旧页 / contradicts 兄弟页。
这个脚本验证:
1. supersedes 目标存在
2. 目标 stage == archived
3. 没有环
4. 没有"被 supersede 但未归档"的孤儿
5. contradicts 对称性(A contradicts B 要求 B 也填 contradicts A)
Usage:
python3 scripts/supersede_check.py
python3 scripts/supersede_check.py --json
"""
import argparse
import json
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
WIKI_ROOT = ROOT / "docs" / "wiki"
FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---", re.DOTALL)
def parse_frontmatter(text: str) -> dict[str, str]:
m = FRONTMATTER_RE.match(text)
if not m:
return {}
out: dict[str, str] = {}
for line in m.group(1).splitlines():
if ":" in line:
key, _, value = line.partition(":")
out[key.strip()] = value.strip()
return out
def parse_list_field(value: str) -> list[str]:
"""Parse a YAML-ish list: [./a.md, ./b.md] -> [./a.md, ./b.md]"""
if not value or value in ("[]", ""):
return []
return re.findall(r"\./[^\s,\]]+", value)
def main() -> int:
parser = argparse.ArgumentParser(description="v2 supersede / contradicts chain validator")
parser.add_argument("--wiki-root", type=Path, default=WIKI_ROOT)
parser.add_argument("--json", action="store_true", help="emit JSON report")
args = parser.parse_args()
if not args.wiki_root.exists():
print(f"supersede_check: {args.wiki_root} does not exist")
return 1
md_files = sorted(args.wiki_root.rglob("*.md"))
pages: dict[str, dict[str, str]] = {}
for path in md_files:
text = path.read_text(encoding="utf-8")
fm = parse_frontmatter(text)
if not fm:
continue
rel = path.relative_to(args.wiki_root).as_posix()
pages[rel] = fm
errors: list[str] = []
orphans: list[str] = [] # 被 supersede 但未 archived
# Pass 1: validate each page's supersedes
for rel, fm in pages.items():
supersedes = parse_list_field(fm.get("supersedes", ""))
for target in supersedes:
target_norm = target.lstrip("./")
if target_norm not in pages:
errors.append(f"supersedes target missing: {rel} -> {target}")
continue
target_stage = pages[target_norm].get("stage", "")
if target_stage != "archived":
errors.append(
f"supersedes target not archived: {rel} -> {target} "
f"(stage={target_stage!r}, expected 'archived')"
)
# Pass 2: detect cycles
# Build adjacency: page -> set of pages it supersedes
adj: dict[str, set[str]] = {}
for rel, fm in pages.items():
supersedes = parse_list_field(fm.get("supersedes", ""))
adj[rel] = {t.lstrip("./") for t in supersedes if t.lstrip("./") in pages}
# DFS cycle detection
WHITE, GRAY, BLACK = 0, 1, 2
color = {node: WHITE for node in adj}
cycles: list[list[str]] = []
def dfs(node: str, path: list[str]) -> None:
color[node] = GRAY
path.append(node)
for nxt in adj.get(node, set()):
if color[nxt] == GRAY:
# Found cycle
if nxt in path:
cycle = path[path.index(nxt):] + [nxt]
cycles.append(cycle)
elif color[nxt] == WHITE:
dfs(nxt, path)
path.pop()
color[node] = BLACK
for node in list(adj.keys()):
if color[node] == WHITE:
dfs(node, [])
for cycle in cycles:
errors.append(f"supersedes cycle detected: {' -> '.join(cycle)}")
# Pass 3: detect orphans (被 supersede 但 stage != archived)
all_superseded_targets: set[str] = set()
for fm in pages.values():
supersedes = parse_list_field(fm.get("supersedes", ""))
for t in supersedes:
all_superseded_targets.add(t.lstrip("./"))
for rel in all_superseded_targets:
if rel in pages and pages[rel].get("stage", "") != "archived":
orphans.append(f"{rel} (stage={pages[rel].get('stage', '')!r})")
# Pass 4: contradicts symmetry
for rel, fm in pages.items():
contradicts = parse_list_field(fm.get("contradicts", ""))
for target in contradicts:
target_norm = target.lstrip("./")
if target_norm not in pages:
continue
target_contradicts = parse_list_field(pages[target_norm].get("contradicts", ""))
if rel not in [t.lstrip("./") for t in target_contradicts]:
errors.append(
f"contradicts asymmetric: {rel} -> {target} "
f"(target {target_norm} does not contradict back)"
)
# Report
report = {
"wiki_root": str(args.wiki_root),
"page_count": len(pages),
"supersedes_total": sum(len(parse_list_field(fm.get("supersedes", ""))) for fm in pages.values()),
"contradicts_total": sum(len(parse_list_field(fm.get("contradicts", ""))) for fm in pages.values()),
"orphans": orphans,
"errors": errors,
"cycles": cycles,
}
if args.json:
print(json.dumps(report, indent=2, ensure_ascii=False))
else:
print("supersede_check:")
print(f"- pages: {report['page_count']}")
print(f"- supersedes total: {report['supersedes_total']}")
print(f"- contradicts total: {report['contradicts_total']}")
if orphans:
print(f"- orphans (superseded but not archived): {len(orphans)}")
for o in orphans:
print(f" - {o}")
if errors:
print(f"- errors: {len(errors)}")
for e in errors:
print(f" - {e}")
return 1
if cycles:
print(f"- cycles: {len(cycles)}")
return 1
print("- OK")
return 0 if not errors and not cycles else 1
if __name__ == "__main__":
sys.exit(main())
|