Skip to content

Commit e5e7615

Browse files
author
t
committed
chore: repository setup
1 parent 4910d49 commit e5e7615

7 files changed

Lines changed: 550 additions & 15 deletions

File tree

.github/scripts/check-docs.py

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
#!/usr/bin/env python3
2+
"""Documentation consistency checks.
3+
4+
Run locally from the repository root:
5+
6+
python .github/scripts/check-docs.py
7+
8+
Verifies three things that would otherwise rot silently:
9+
10+
1. every relative link and heading anchor resolves
11+
2. every module on disk is linked exactly once from a family guide
12+
3. every module count written in prose matches what is on disk
13+
14+
Exits non-zero on the first category that fails.
15+
"""
16+
17+
import os
18+
import re
19+
import sys
20+
from collections import Counter
21+
from pathlib import Path
22+
23+
ROOT = Path(__file__).resolve().parents[2]
24+
MODULES_DIR = ROOT / "src" / "modules"
25+
FAMILIES = ("azure", "entra", "fortios", "github")
26+
27+
failures = []
28+
29+
30+
def note(category, message):
31+
failures.append("%s: %s" % (category, message))
32+
33+
34+
def doc_files():
35+
files = sorted((ROOT / "docs").rglob("*.md"))
36+
for extra in ("README.md", "CLAUDE.md"):
37+
path = ROOT / extra
38+
if path.exists():
39+
files.append(path)
40+
return files
41+
42+
43+
def slugify(heading):
44+
text = heading.strip().lower().replace("`", "")
45+
text = re.sub(r"[^\w\s-]", "", text)
46+
return re.sub(r"\s+", "-", text).strip("-")
47+
48+
49+
def rel(path):
50+
return os.path.relpath(path, ROOT).replace(os.sep, "/")
51+
52+
53+
# ---------------------------------------------------------------------------
54+
# 1. links and anchors
55+
# ---------------------------------------------------------------------------
56+
57+
def check_links(files):
58+
anchors = {}
59+
for path in files:
60+
text = path.read_text(encoding="utf-8")
61+
anchors[rel(path)] = {
62+
slugify(m.group(2)) for m in re.finditer(r"^(#{1,6})\s+(.*)$", text, re.M)
63+
}
64+
65+
checked = 0
66+
link_re = re.compile(r"\[([^\]]*)\]\(([^)\s]+)\)")
67+
for path in files:
68+
text = path.read_text(encoding="utf-8")
69+
for match in link_re.finditer(text):
70+
target = match.group(2)
71+
if target.startswith(("http://", "https://", "mailto:")):
72+
continue
73+
checked += 1
74+
file_part, _, anchor = target.partition("#")
75+
if file_part:
76+
resolved = (path.parent / file_part).resolve()
77+
if not resolved.exists():
78+
note("links", "%s -> missing path %s" % (rel(path), target))
79+
continue
80+
key = rel(resolved)
81+
else:
82+
key = rel(path)
83+
if anchor:
84+
if key not in anchors:
85+
note("links", "%s -> anchor on non-doc %s" % (rel(path), target))
86+
elif anchor not in anchors[key]:
87+
note("links", "%s -> missing anchor %s" % (rel(path), target))
88+
return checked
89+
90+
91+
# ---------------------------------------------------------------------------
92+
# 2. inventory
93+
# ---------------------------------------------------------------------------
94+
95+
def check_inventory(on_disk):
96+
linked = Counter()
97+
for guide in sorted((ROOT / "docs" / "modules").glob("*.md")):
98+
text = guide.read_text(encoding="utf-8")
99+
for match in re.finditer(r"\(\.\./\.\./src/modules/([^/)]+)/\)", text):
100+
linked[match.group(1)] += 1
101+
102+
for name in sorted(on_disk - set(linked)):
103+
note("inventory", "%s is not linked from any family guide" % name)
104+
for name in sorted(set(linked) - on_disk):
105+
note("inventory", "%s is linked but does not exist on disk" % name)
106+
for name, count in sorted(linked.items()):
107+
if count > 1:
108+
note("inventory", "%s is linked %d times (expected once)" % (name, count))
109+
return len(linked)
110+
111+
112+
# ---------------------------------------------------------------------------
113+
# 3. counts written in prose
114+
# ---------------------------------------------------------------------------
115+
116+
def expect(label, pattern, text, source, actual):
117+
"""Assert the single capture group of `pattern` equals `actual`."""
118+
match = re.search(pattern, text, re.M)
119+
if not match:
120+
note("counts", "%s: pattern for %s no longer matches — update the checker "
121+
"or restore the sentence" % (source, label))
122+
return
123+
found = int(match.group(1))
124+
if found != actual:
125+
note("counts", "%s: %s says %d, disk says %d" % (source, label, found, actual))
126+
127+
128+
def check_counts(on_disk):
129+
per_family = {f: len([m for m in on_disk if m.startswith(f + "-")]) for f in FAMILIES}
130+
total = len(on_disk)
131+
submodules = len([
132+
p for p in MODULES_DIR.glob("*/**/versions.tf")
133+
if p.parent != MODULES_DIR / p.relative_to(MODULES_DIR).parts[0]
134+
])
135+
136+
for family in FAMILIES:
137+
guide = ROOT / "docs" / "modules" / ("%s.md" % family)
138+
expect("header count", r"^(\d+) modules on ", guide.read_text(encoding="utf-8"),
139+
rel(guide), per_family[family])
140+
141+
docs_index = (ROOT / "docs" / "README.md").read_text(encoding="utf-8")
142+
root_readme = (ROOT / "README.md").read_text(encoding="utf-8")
143+
for family in FAMILIES:
144+
expect("%s row" % family,
145+
r"\[[^\]]+\]\(modules/%s\.md\)[^|]*\|[^|]*\|\s*(\d+)\s*\|" % family,
146+
docs_index, "docs/README.md", per_family[family])
147+
expect("%s row" % family,
148+
r"\|[^|]*\|[^|]*\|\s*(\d+)\s*\|[^|]*docs/modules/%s\.md" % family,
149+
root_readme, "README.md", per_family[family])
150+
151+
expect("module total", r"^(\d+) modules plus \d+ nested submodules",
152+
root_readme, "README.md", total)
153+
expect("submodule total", r"^\d+ modules plus (\d+) nested submodules",
154+
root_readme, "README.md", submodules)
155+
156+
claude = (ROOT / "CLAUDE.md").read_text(encoding="utf-8")
157+
expect("module total", r"\*\*(\d+)\*\* modules plus", claude, "CLAUDE.md", total)
158+
expect("submodule total", r"\*\*\d+\*\* modules plus (\d+)\s*\n?nested submodules",
159+
claude, "CLAUDE.md", submodules)
160+
expect("directory total", r"\*\*(\d+) module directories\*\*",
161+
claude, "CLAUDE.md", total + submodules)
162+
163+
164+
def main():
165+
if not MODULES_DIR.is_dir():
166+
print("error: %s not found — run from the repository root" % rel(MODULES_DIR))
167+
return 2
168+
169+
on_disk = {p.name for p in MODULES_DIR.iterdir() if p.is_dir()}
170+
171+
files = doc_files()
172+
checked = check_links(files)
173+
linked = check_inventory(on_disk)
174+
check_counts(on_disk)
175+
176+
print("docs checked : %d" % len(files))
177+
print("links resolved : %d" % checked)
178+
print("modules on disk : %d" % len(on_disk))
179+
print("modules linked : %d" % linked)
180+
181+
if failures:
182+
print("\n%d problem(s):\n" % len(failures))
183+
for line in failures:
184+
print(" %s" % line)
185+
return 1
186+
187+
print("\nall documentation checks passed")
188+
return 0
189+
190+
191+
if __name__ == "__main__":
192+
sys.exit(main())

0 commit comments

Comments
 (0)