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())