Skip to content

Commit 3d044fc

Browse files
committed
feat: add README tree drift check and wire into CI
what: create scripts/check-readme-tree.py that parses the ## Repo Layout tree from README.md, extracts file paths, and compares against git ls-files why: the manually maintained repo tree in README silently drifts when files are added or removed; this check prevents that - Parses ASCII tree format tracking directory depth via indentation - Handles wildcard entries (*.md) by globbing the corresponding directory - Flags files in the tree but not tracked (stale) and tracked but not in tree (omitted) - Added scripts/check-version-consistency.py and scripts/check-readme-tree.py to README tree - Wired check-readme-tree.py into CI and validate-ci.py required commands
1 parent bfc0fc9 commit 3d044fc

4 files changed

Lines changed: 133 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ jobs:
6060
- name: Check version consistency
6161
run: python3 scripts/check-version-consistency.py
6262

63+
- name: Check README tree matches tracked files
64+
run: python3 scripts/check-readme-tree.py
65+
6366
- name: Validate CI policy
6467
run: python3 scripts/validate-ci.py
6568

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,8 @@ python-project-workflow/
236236
├── scripts/ # Repository maintenance and validation tools
237237
│ ├── payload-manifest.json # Declares canonical files copied into the payload
238238
│ ├── sync-payload.sh # Synchronizes or checks the runtime payload mirror
239+
│ ├── check-version-consistency.py # Validates version alignment across manifests and tags
240+
│ ├── check-readme-tree.py # Ensures README repo-layout tree matches tracked files
239241
│ ├── test-validate-ci.py # Regression tests for CI policy enforcement
240242
│ ├── test-sync-payload.py # Regression tests for payload drift behavior
241243
│ ├── validate-ci.py # Enforces CI routing, required gates, toolchain policy, and action pins

scripts/check-readme-tree.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
#!/usr/bin/env python3
2+
"""Check that the README repo-layout tree matches tracked files."""
3+
4+
from __future__ import annotations
5+
6+
import re
7+
import subprocess
8+
import sys
9+
from pathlib import Path
10+
11+
12+
ROOT = Path(__file__).resolve().parents[1]
13+
README = ROOT / "README.md"
14+
15+
TREE_SECTION_RE = re.compile(
16+
r"^## Repo Layout\n+```text\n(?P<tree>.*?)\n```",
17+
re.MULTILINE | re.DOTALL,
18+
)
19+
20+
# Tree entry: optional indent (groups of 4 chars: '│ ' or ' '), a branch
21+
# marker '├── ' or '└── ', the entry name, and an optional '# comment'.
22+
TREE_ENTRY_RE = re.compile(
23+
r"^(?P<indent>(?:[│ ] )*)(?P<branch>[├└]──[─ ])(?P<name>[^#\n]+?)(?:\s*#.*)?$"
24+
)
25+
26+
27+
def get_tracked_files() -> set[str]:
28+
"""Return the set of all tracked file paths, relative to repo root."""
29+
try:
30+
result = subprocess.run(
31+
["git", "ls-files"],
32+
capture_output=True,
33+
text=True,
34+
check=True,
35+
cwd=ROOT,
36+
)
37+
except (OSError, subprocess.CalledProcessError) as exc:
38+
print(f"FAIL: could not list git files: {exc}", file=sys.stderr)
39+
sys.exit(1)
40+
return {line for line in result.stdout.splitlines() if line}
41+
42+
43+
def parse_tree_paths(tree_text: str) -> set[str]:
44+
"""Parse the ASCII tree and return the set of file paths declared.
45+
46+
Each indent level is exactly 4 characters ('│ ' or ' ').
47+
Directory entries end with '/'; file entries are collected.
48+
"""
49+
paths: set[str] = set()
50+
dir_stack: list[str] = []
51+
52+
for line in tree_text.splitlines():
53+
m = TREE_ENTRY_RE.match(line)
54+
if not m:
55+
continue
56+
57+
raw_indent = m.group("indent")
58+
name = m.group("name").strip()
59+
60+
# One indent level is 4 chars — count them
61+
depth = len(raw_indent) // 4
62+
63+
# Trim directory stack to current depth
64+
while len(dir_stack) > depth:
65+
dir_stack.pop()
66+
67+
if name.endswith("/"):
68+
# Directory entry
69+
dir_name = name.rstrip("/")
70+
if len(dir_stack) <= depth:
71+
dir_stack.append(dir_name)
72+
else:
73+
dir_stack[depth] = dir_name
74+
dir_stack = dir_stack[: depth + 1]
75+
else:
76+
# File entry — skip wildcards like *.md
77+
if "*" in name:
78+
# Generate matching files from the filesystem
79+
dir_path = "/".join(dir_stack) + ("/" if dir_stack else "")
80+
match_path = ROOT / dir_path
81+
if match_path.exists():
82+
for f in sorted(match_path.glob(name)):
83+
rel = f.relative_to(ROOT).as_posix()
84+
paths.add(rel)
85+
continue
86+
full_path = "/".join(dir_stack) + ("/" if dir_stack else "") + name
87+
paths.add(full_path)
88+
89+
return paths
90+
91+
92+
def main() -> int:
93+
if not README.exists():
94+
print("FAIL: README.md not found", file=sys.stderr)
95+
return 1
96+
97+
readme_text = README.read_text(encoding="utf-8")
98+
99+
m = TREE_SECTION_RE.search(readme_text)
100+
if not m:
101+
print("FAIL: could not find ## Repo Layout section with ```text tree in README", file=sys.stderr)
102+
return 1
103+
104+
tracked = get_tracked_files()
105+
declared = parse_tree_paths(m.group("tree"))
106+
107+
stale = declared - tracked
108+
omitted = tracked - declared
109+
110+
if stale:
111+
for f in sorted(stale):
112+
print(f"STALE: {f} (in README tree but not tracked)")
113+
if omitted:
114+
for f in sorted(omitted):
115+
print(f"OMITTED: {f} (tracked but not in README tree)")
116+
117+
if stale or omitted:
118+
print()
119+
print("FAIL: README repo-layout tree does not match tracked files", file=sys.stderr)
120+
return 1
121+
122+
print("PASS: README repo-layout tree matches tracked files")
123+
return 0
124+
125+
126+
if __name__ == "__main__":
127+
raise SystemExit(main())

scripts/validate-ci.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
REQUIRED_VALIDATE_COMMANDS = (
1515
"python3 .github/scripts/check-portability.py",
1616
"python3 scripts/check-version-consistency.py",
17+
"python3 scripts/check-readme-tree.py",
1718
"python3 scripts/validate-ci.py",
1819
"python3 scripts/validate.py",
1920
"python3 scripts/test-validate-ci.py",

0 commit comments

Comments
 (0)