From 3d044fcae1e8334791cfa110f5ecbf11bf519dd1 Mon Sep 17 00:00:00 2001 From: CodeSigils Date: Fri, 17 Jul 2026 11:50:30 +0300 Subject: [PATCH] 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 --- .github/workflows/ci.yml | 3 + README.md | 2 + scripts/check-readme-tree.py | 127 +++++++++++++++++++++++++++++++++++ scripts/validate-ci.py | 1 + 4 files changed, 133 insertions(+) create mode 100644 scripts/check-readme-tree.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 083feed..d03f8b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,6 +60,9 @@ jobs: - name: Check version consistency run: python3 scripts/check-version-consistency.py + - name: Check README tree matches tracked files + run: python3 scripts/check-readme-tree.py + - name: Validate CI policy run: python3 scripts/validate-ci.py diff --git a/README.md b/README.md index 00e1985..9360c14 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,8 @@ python-project-workflow/ ├── scripts/ # Repository maintenance and validation tools │ ├── payload-manifest.json # Declares canonical files copied into the payload │ ├── sync-payload.sh # Synchronizes or checks the runtime payload mirror +│ ├── check-version-consistency.py # Validates version alignment across manifests and tags +│ ├── check-readme-tree.py # Ensures README repo-layout tree matches tracked files │ ├── test-validate-ci.py # Regression tests for CI policy enforcement │ ├── test-sync-payload.py # Regression tests for payload drift behavior │ ├── validate-ci.py # Enforces CI routing, required gates, toolchain policy, and action pins diff --git a/scripts/check-readme-tree.py b/scripts/check-readme-tree.py new file mode 100644 index 0000000..811fe5a --- /dev/null +++ b/scripts/check-readme-tree.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +"""Check that the README repo-layout tree matches tracked files.""" + +from __future__ import annotations + +import re +import subprocess +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +README = ROOT / "README.md" + +TREE_SECTION_RE = re.compile( + r"^## Repo Layout\n+```text\n(?P.*?)\n```", + re.MULTILINE | re.DOTALL, +) + +# Tree entry: optional indent (groups of 4 chars: '│ ' or ' '), a branch +# marker '├── ' or '└── ', the entry name, and an optional '# comment'. +TREE_ENTRY_RE = re.compile( + r"^(?P(?:[│ ] )*)(?P[├└]──[─ ])(?P[^#\n]+?)(?:\s*#.*)?$" +) + + +def get_tracked_files() -> set[str]: + """Return the set of all tracked file paths, relative to repo root.""" + try: + result = subprocess.run( + ["git", "ls-files"], + capture_output=True, + text=True, + check=True, + cwd=ROOT, + ) + except (OSError, subprocess.CalledProcessError) as exc: + print(f"FAIL: could not list git files: {exc}", file=sys.stderr) + sys.exit(1) + return {line for line in result.stdout.splitlines() if line} + + +def parse_tree_paths(tree_text: str) -> set[str]: + """Parse the ASCII tree and return the set of file paths declared. + + Each indent level is exactly 4 characters ('│ ' or ' '). + Directory entries end with '/'; file entries are collected. + """ + paths: set[str] = set() + dir_stack: list[str] = [] + + for line in tree_text.splitlines(): + m = TREE_ENTRY_RE.match(line) + if not m: + continue + + raw_indent = m.group("indent") + name = m.group("name").strip() + + # One indent level is 4 chars — count them + depth = len(raw_indent) // 4 + + # Trim directory stack to current depth + while len(dir_stack) > depth: + dir_stack.pop() + + if name.endswith("/"): + # Directory entry + dir_name = name.rstrip("/") + if len(dir_stack) <= depth: + dir_stack.append(dir_name) + else: + dir_stack[depth] = dir_name + dir_stack = dir_stack[: depth + 1] + else: + # File entry — skip wildcards like *.md + if "*" in name: + # Generate matching files from the filesystem + dir_path = "/".join(dir_stack) + ("/" if dir_stack else "") + match_path = ROOT / dir_path + if match_path.exists(): + for f in sorted(match_path.glob(name)): + rel = f.relative_to(ROOT).as_posix() + paths.add(rel) + continue + full_path = "/".join(dir_stack) + ("/" if dir_stack else "") + name + paths.add(full_path) + + return paths + + +def main() -> int: + if not README.exists(): + print("FAIL: README.md not found", file=sys.stderr) + return 1 + + readme_text = README.read_text(encoding="utf-8") + + m = TREE_SECTION_RE.search(readme_text) + if not m: + print("FAIL: could not find ## Repo Layout section with ```text tree in README", file=sys.stderr) + return 1 + + tracked = get_tracked_files() + declared = parse_tree_paths(m.group("tree")) + + stale = declared - tracked + omitted = tracked - declared + + if stale: + for f in sorted(stale): + print(f"STALE: {f} (in README tree but not tracked)") + if omitted: + for f in sorted(omitted): + print(f"OMITTED: {f} (tracked but not in README tree)") + + if stale or omitted: + print() + print("FAIL: README repo-layout tree does not match tracked files", file=sys.stderr) + return 1 + + print("PASS: README repo-layout tree matches tracked files") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validate-ci.py b/scripts/validate-ci.py index aca1ab1..e3dc577 100644 --- a/scripts/validate-ci.py +++ b/scripts/validate-ci.py @@ -14,6 +14,7 @@ REQUIRED_VALIDATE_COMMANDS = ( "python3 .github/scripts/check-portability.py", "python3 scripts/check-version-consistency.py", + "python3 scripts/check-readme-tree.py", "python3 scripts/validate-ci.py", "python3 scripts/validate.py", "python3 scripts/test-validate-ci.py",