-
-
Notifications
You must be signed in to change notification settings - Fork 314
Expand file tree
/
Copy pathdocs_check.py
More file actions
272 lines (230 loc) · 10.8 KB
/
Copy pathdocs_check.py
File metadata and controls
272 lines (230 loc) · 10.8 KB
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
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
"""Mintlify docs integrity checker — run before merging any docs PR."""
from __future__ import annotations
import glob
import json
import os
import re
import subprocess
import sys
from typing import Any, Callable, cast
try:
from rich.console import Console as _Console
_console = _Console()
def _print_pass(label: str) -> None:
_console.print(f"[bold green]pass[/bold green] {label}")
def _print_fail(label: str, msgs: list[str]) -> None:
_console.print(f"[bold red]FAIL[/bold red] {label}")
for m in msgs:
_console.print(f"[dim] - {m}[/dim]")
def _print_summary(failures: list[str]) -> None:
_console.print()
if failures:
_console.print(f"[bold red]FAILED[/bold red] {len(failures)} issue(s) found")
else:
_console.print("[bold green]All checks passed[/bold green]")
except ModuleNotFoundError:
def _print_pass(label: str) -> None: # type: ignore[misc]
print(f"pass {label}")
def _print_fail(label: str, msgs: list[str]) -> None: # type: ignore[misc]
print(f"FAIL {label}")
for m in msgs:
print(f" - {m}")
def _print_summary(failures: list[str]) -> None: # type: ignore[misc]
print()
if failures:
print(f"FAILED {len(failures)} issue(s) found")
else:
print("All checks passed")
DOCS = "docs"
ALL_MD: list[str] = glob.glob(f"{DOCS}/**/*.md", recursive=True)
failures: list[str] = []
def check(label: str) -> Callable[[Callable[[], list[str]]], None]:
"""Decorator: run a check function, print result, collect failures."""
def decorator(fn: Callable[[], list[str]]) -> None:
issues = fn()
if issues:
_print_fail(label, issues)
failures.extend(issues)
else:
_print_pass(label)
return decorator
def read(path: str) -> str:
return open(path, encoding="utf-8").read()
# ── 1. docs.json is valid JSON ────────────────────────────────────────────────
@check("docs.json is valid JSON")
def _() -> list[str]:
try:
json.loads(read(f"{DOCS}/docs.json"))
return []
except Exception as e:
return [str(e)]
# ── 2. Every nav page exists on disk ─────────────────────────────────────────
@check("All nav pages exist on disk")
def _() -> list[str]:
cfg: Any = json.loads(read(f"{DOCS}/docs.json"))
def nav_pages(obj: Any) -> list[str]:
"""Recursively collect strings from 'pages' arrays only."""
result: list[str] = []
if isinstance(obj, dict):
d = cast(dict[str, Any], obj)
for page in cast(list[Any], d.get("pages", [])):
if isinstance(page, str):
result.append(page)
else:
result.extend(nav_pages(page))
for v in d.values():
if isinstance(v, (dict, list)):
result.extend(nav_pages(v))
elif isinstance(obj, list):
for item in cast(list[Any], obj):
result.extend(nav_pages(item))
return result
return [
f"missing: {p}"
for p in set(nav_pages(cfg))
if not p.startswith("http") and not os.path.exists(f"{DOCS}/{p}.md")
]
# ── 3. Internal Card hrefs resolve ───────────────────────────────────────────
@check("All internal Card hrefs resolve")
def _() -> list[str]:
issues: list[str] = []
for fpath in ALL_MD:
for m in re.finditer(r'href=["\'](?!http)([^"\'#]+)["\']', read(fpath)):
href = m.group(1).strip()
target = os.path.normpath(os.path.join(os.path.dirname(fpath), href)) + ".md"
if not os.path.exists(target):
issues.append(f"{fpath}: href '{href}'")
return issues
# ── 4. No stale repo URLs ─────────────────────────────────────────────────────
@check("No stale repo URLs")
def _() -> list[str]:
stale = ["Hawksight-AI/semantica", "semantica-dev/semantica"]
files = ALL_MD + [f"{DOCS}/docs.json"]
return [
f"{f}: '{pat}'"
for f in files
for pat in stale
if pat in read(f)
]
# ── 5. All reference pages have frontmatter ───────────────────────────────────
@check("All reference pages have frontmatter")
def _() -> list[str]:
return [
os.path.basename(f)
for f in glob.glob(f"{DOCS}/reference/*.md")
if not read(f).startswith("---")
]
# ── 6. No known-wrong class names ────────────────────────────────────────────
@check("No known-wrong class names in reference pages")
def _() -> list[str]:
banned: list[tuple[str, str]] = [
(r"\bBaseIngestor\b", "docs/architecture.md"),
(r"\bBaseExtractor\b", "docs/architecture.md"),
(r"\bBasePlugin\b", "docs/architecture.md"),
(r"\bDataNormalizer\b", "docs/reference/normalize.md"),
(r"\bEntityResolver\b", "docs/reference/deduplication.md"),
(r"\bDeductiveEngine\b", "docs/reference/reasoning.md"),
(r"\bAbductiveEngine\b", "docs/reference/reasoning.md"),
(r"\bGraphMLExporter\b", "docs/reference/export.md"),
(r"\bArangoExporter\b(?!.*AQL)", "docs/reference/export.md"),
(r"(?<!Temporal)(?<!Graph)\bReasoningEngine\b", "docs/reference/reasoning.md"),
]
return [
f"{fpath}: '{pat}'"
for pat, fpath in banned
if os.path.exists(fpath) and re.search(pat, read(fpath))
]
# ── 7. No Python 3.9+ type syntax in code blocks ─────────────────────────────
@check("No Python 3.9+ type syntax in code blocks (3.8 compat)")
def _() -> list[str]:
issues: list[str] = []
for fpath in ALL_MD:
in_block = False
for i, line in enumerate(open(fpath, encoding="utf-8"), 1):
if line.strip().startswith("```"):
in_block = not in_block
if in_block and re.search(r":\s*(list|dict|tuple|set)\[", line):
issues.append(f"{fpath}:{i}: {line.rstrip()}")
return issues
# ── 8. index.md covers all 27 modules ────────────────────────────────────────
@check("All 27 modules present in index.md")
def _() -> list[str]:
modules = [
"semantica.ingest", "semantica.parse", "semantica.split", "semantica.normalize",
"semantica.semantic_extract", "semantica.kg", "semantica.ontology", "semantica.reasoning",
"semantica.embeddings", "semantica.vector_store", "semantica.graph_store",
"semantica.triplet_store", "semantica.context", "semantica.provenance",
"semantica.change_management", "semantica.deduplication", "semantica.conflicts",
"semantica.export", "semantica.visualization", "semantica.pipeline", "semantica.seed",
"semantica.llms", "semantica.mcp_server", "semantica.explorer", "semantica.evals",
"semantica.utils", "semantica.core",
]
index = read(f"{DOCS}/index.md")
return [m for m in modules if m not in index]
# ── 9. JSX component tags are balanced in every page ─────────────────────────
@check("All Mintlify JSX component tags are balanced")
def _() -> list[str]:
# Paired block-level components that must open and close.
COMPONENTS = [
"AccordionGroup", "Accordion", "Steps", "Step",
"CodeGroup", "Tabs", "Tab", "CardGroup", "Card",
"Expandable",
]
issues: list[str] = []
for fpath in ALL_MD:
content = read(fpath)
lines = content.splitlines()
for comp in COMPONENTS:
# Count opening and closing tags (ignore self-closing <Comp />)
opens = len(re.findall(rf"<{comp}[\s>]", content))
closes = len(re.findall(rf"</{comp}>", content))
if opens != closes:
issues.append(
f"{fpath}: <{comp}> opened {opens}x but closed {closes}x"
)
# Check code fences are balanced (odd fence count = unclosed block)
fence_count = sum(
1 for ln in lines if ln.strip().startswith("```")
)
if fence_count % 2 != 0:
issues.append(f"{fpath}: odd number of ``` fences — unclosed code block")
return issues
# ── 10. Mintlify export succeeds (requires Node.js / npx) ────────────────────
@check("Mintlify export builds without errors")
def _() -> list[str]:
npx = "npx.cmd" if sys.platform == "win32" else "npx"
try:
result = subprocess.run(
[npx, "--yes", "mintlify@4.2.632", "export", "--output", "export_ci_check.zip"],
cwd=DOCS,
capture_output=True,
text=True,
timeout=300,
)
# Clean up zip regardless of outcome
zip_path = os.path.join(DOCS, "export_ci_check.zip")
if os.path.exists(zip_path):
os.remove(zip_path)
combined = (result.stdout or "") + (result.stderr or "")
if result.returncode != 0:
# On Windows, npm cleanup raises EPERM on temp dirs — not a real
# export failure. Treat as a skip rather than a hard failure.
if sys.platform == "win32" and "EPERM" in combined and \
"could not be generated" not in combined:
return [] # Windows temp-cleanup noise; real CI runs on Linux
# Show first 60 lines (where per-page errors are logged) AND
# last 20 lines (where the summary error appears).
all_lines = combined.strip().splitlines()
head = "\n".join(all_lines[:60])
tail = "\n".join(all_lines[-20:]) if len(all_lines) > 60 else ""
output = head + ("\n...\n" + tail if tail else "")
return [f"mintlify export failed (exit {result.returncode}):\n{output}"]
return []
except FileNotFoundError:
return ["npx not found — skipping Mintlify export check (Node.js required)"]
except subprocess.TimeoutExpired:
return ["mintlify export timed out after 300 s"]
# ── Summary ───────────────────────────────────────────────────────────────────
_print_summary(failures)
if failures:
sys.exit(1)