File size: 7,257 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
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
from __future__ import annotations
# llm-wiki-version: 2.0.0
# runtime: ci-safe (structural only)

import re
import sys
from pathlib import Path


ROOT = Path(__file__).resolve().parents[1]
WIKI_ROOT = ROOT / "docs" / "wiki"

REQUIRED_FILES = [
    "README.md",
    "SCHEMA.md",
    "index.md",
    "log.md",
    "project-overview.md",
    "current-status.md",
    "sources-and-data.md",
    "github-and-raw-strategy.md",
]

LOG_HEADER_RE = re.compile(r"^## \[\d{4}-\d{2}-\d{2}\] .+ \| .+$")
FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---", re.DOTALL)
FRONTMATTER_EXEMPT = {"index.md", "log.md", "README.md", "SCHEMA.md"}
REQUIRED_FRONTMATTER_KEYS = {"title", "source", "created"}

# v2 升级:log.md 支持表格格式("table")或 H2 日期头("header"),默认 "table"
# 表格格式 = 第一行以 "| " 开头(markdown 表格)
LOG_FORMAT = "table"  # "table" | "header"


def is_log_table_format(text: str) -> bool:
    """log.md 是否用 markdown 表格(≥3 行以 | 开头)。"""
    table_lines = [ln for ln in text.splitlines() if ln.startswith("|")]
    return len(table_lines) >= 3

# Don't validate links inside fenced code (```), indented code, or inline code.
FENCE_RE = re.compile(r"^(\s*)(```|~~~)")
INLINE_CODE_RE = re.compile(r"`[^`\n]*`")
LINK_OPEN_RE = re.compile(r"!?\[(?:[^\[\]\\]|\\.)*\]\(")


def strip_inline_code(line: str) -> str:
    return INLINE_CODE_RE.sub("", line)


def iter_link_targets(text: str) -> list[str]:
    """Return every (link target) found outside fenced/inline code.

    Properly handles balanced parens inside the URL portion (rare but legal).
    """
    out: list[str] = []
    in_fence = False
    fence_marker = ""
    for raw_line in text.splitlines():
        m = FENCE_RE.match(raw_line)
        if m:
            marker = m.group(2)
            if not in_fence:
                in_fence = True
                fence_marker = marker
            elif marker == fence_marker:
                in_fence = False
                fence_marker = ""
            continue
        if in_fence:
            continue
        line = strip_inline_code(raw_line)
        pos = 0
        while True:
            m = LINK_OPEN_RE.search(line, pos)
            if not m:
                break
            start = m.end()
            depth = 1
            i = start
            while i < len(line) and depth > 0:
                ch = line[i]
                if ch == "\\" and i + 1 < len(line):
                    i += 2
                    continue
                if ch == "(":
                    depth += 1
                elif ch == ")":
                    depth -= 1
                    if depth == 0:
                        break
                i += 1
            if depth == 0:
                target = line[start:i].strip()
                if target.startswith("<") and target.endswith(">"):
                    target = target[1:-1].strip()
                title_match = re.search(r'\s+["\'(].*["\')]$', target)
                if title_match:
                    target = target[: title_match.start()].strip()
                out.append(target)
                pos = i + 1
            else:
                pos = len(line)
    return out


def check_frontmatter(path: Path, text: str) -> list[str]:
    errs: list[str] = []
    if path.name in FRONTMATTER_EXEMPT:
        return errs
    m = FRONTMATTER_RE.match(text)
    if not m:
        errs.append(f"missing frontmatter: {path.relative_to(ROOT)}")
        return errs
    body = m.group(1)
    found_keys: set[str] = set()
    for line in body.splitlines():
        if ":" in line:
            key = line.split(":", 1)[0].strip()
            found_keys.add(key)
    missing = REQUIRED_FRONTMATTER_KEYS - found_keys
    if missing:
        errs.append(f"missing frontmatter keys in {path.relative_to(ROOT)}: {', '.join(sorted(missing))}")
    return errs


def resolve_link(source_file: Path, target: str) -> Path | None:
    if target.startswith(("http://", "https://", "mailto:", "tel:", "ftp://")):
        return None
    if target.startswith("#"):
        return None
    target = target.split("#", 1)[0].split("?", 1)[0]
    if not target:
        return None
    return (source_file.parent / target).resolve()


def index_referenced_pages(index_path: Path) -> set[str]:
    """Set of wiki-relative posix paths that index.md actually links to.

    Uses real link parsing instead of substring matching, so a page named
    `policy.md` doesn't accidentally pass because index.md mentions
    `pricing-policy.md`.
    """
    if not index_path.exists():
        return set()
    text = index_path.read_text(encoding="utf-8")
    refs: set[str] = set()
    for raw_target in iter_link_targets(text):
        resolved = resolve_link(index_path, raw_target)
        if resolved is None:
            continue
        try:
            rel = resolved.relative_to(WIKI_ROOT.resolve()).as_posix()
        except ValueError:
            continue
        refs.add(rel)
    return refs


def main() -> int:
    errors: list[str] = []
    if not WIKI_ROOT.exists():
        print("wiki_check: docs/wiki does not exist")
        return 1

    for rel in REQUIRED_FILES:
        if not (WIKI_ROOT / rel).exists():
            errors.append(f"missing required file: docs/wiki/{rel}")

    md_files = sorted(WIKI_ROOT.rglob("*.md"))
    index_refs = index_referenced_pages(WIKI_ROOT / "index.md")
    EXEMPT_FROM_INDEX = {"README.md", "SCHEMA.md", "log.md", "index.md"}

    fm_ok = 0
    for path in md_files:
        text = path.read_text(encoding="utf-8")

        fm_errors = check_frontmatter(path, text)
        errors.extend(fm_errors)
        if not fm_errors and path.name not in FRONTMATTER_EXEMPT:
            fm_ok += 1

        if path.name == "log.md":
            # v2 升级:表格格式下跳过 H2 日期头校验
            if LOG_FORMAT == "header":
                for line in text.splitlines():
                    if line.startswith("## ") and not LOG_HEADER_RE.match(line):
                        errors.append(f"bad log header format in {path.relative_to(ROOT)}: {line}")
            elif LOG_FORMAT == "table":
                if not is_log_table_format(text):
                    errors.append(f"log.md not in table format (LOG_FORMAT=table) in {path.relative_to(ROOT)}")

        rel = path.relative_to(WIKI_ROOT).as_posix()
        if rel not in EXEMPT_FROM_INDEX and rel not in index_refs:
            errors.append(f"index.md does not reference docs/wiki/{rel}")

        for raw_target in iter_link_targets(text):
            resolved = resolve_link(path, raw_target)
            if resolved is None:
                continue
            if not resolved.exists():
                errors.append(f"broken link in {path.relative_to(ROOT)} -> {raw_target}")

    if errors:
        print("wiki_check: FAILED")
        for item in errors:
            print(f"- {item}")
        return 1

    print("wiki_check: OK")
    print(f"- markdown files: {len(md_files)}")
    print(f"- required files: {len(REQUIRED_FILES)}")
    print(f"- frontmatter valid: {fm_ok}")
    print(f"- index.md links: {len(index_refs)}")
    print(f"- wiki root: {WIKI_ROOT}")
    return 0


if __name__ == "__main__":
    sys.exit(main())