Files
teng-lin--notebooklm-py/scripts/check_claude_md_freshness.py
T
wehub-resource-sync 09e9f3545f
Test / Code Quality (push) Has been cancelled
Test / Test (macos-latest, Python 3.10) (push) Has been cancelled
Test / Test (macos-latest, Python 3.11) (push) Has been cancelled
Test / Test (macos-latest, Python 3.12) (push) Has been cancelled
Test / Test (macos-latest, Python 3.13) (push) Has been cancelled
Test / Test (macos-latest, Python 3.14) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.10) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.11) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.12) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.13) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.14) (push) Has been cancelled
Test / Test (windows-latest, Python 3.10) (push) Has been cancelled
Test / Test (windows-latest, Python 3.11) (push) Has been cancelled
Test / Test (windows-latest, Python 3.12) (push) Has been cancelled
Test / Test (windows-latest, Python 3.13) (push) Has been cancelled
Test / Test (windows-latest, Python 3.14) (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled
dependency-audit / pip-audit (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:30:13 +08:00

226 lines
8.0 KiB
Python

"""Assert the repo-structure file map is fresh in both directions.
The hand-maintained module map (the ``### Repository Structure`` tree) lives in
``docs/architecture.md`` — it was moved there from CLAUDE.md to keep CLAUDE.md
slim; the ``--claude-md`` flag is kept (name unchanged for back-compat) but now
defaults to the architecture doc. This gate prevents silent drift:
* documented paths must still exist; and
* every ``src/notebooklm`` module/package (including subpackage members) must
be documented or explicitly omitted with a reason.
Usage:
python scripts/check_claude_md_freshness.py
python scripts/check_claude_md_freshness.py --claude-md path/to/doc.md
Exit codes:
0 The structure doc (docs/architecture.md) is fresh.
1 One or more paths are stale or missing from the map.
2 Argument error or the structure doc not found.
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
_DOCUMENTED_ROOTS = ("src/notebooklm", "tests")
_OMISSIONS_HEADING = "### Repository Structure Intentional Omissions"
_OMISSION_BULLET_RE = re.compile(r"^\s*[-*]\s+")
_OMISSION_PATH_RE = re.compile(r"^\s*[-*]\s+`(?P<path>src/notebooklm/[^`]+)`")
_IGNORED_PATH_RE = re.compile(
r"^\s*[-*]\s+`(?P<path>src/notebooklm/[^`]+)`\s+(?:--|-|:)\s+(?P<reason>.+?)\s*$"
)
def _extract_paths(text: str) -> list[str]:
paths: list[str] = []
stack: list[tuple[int, str]] = []
for line in text.splitlines():
# Do not strip yet, we need leading spaces for indent calculation
trimmed = line.strip()
if not trimmed or any(trimmed.startswith(p) for p in ("|", "#", "```")):
continue
# Determine indentation level and clean the line
indent = 0
clean_line = trimmed
found_marker = False
for marker in ("├── ", "└── ", "│ "):
if marker in line:
# Calculate depth based on the position of the marker
# We expect 4 spaces per level (or equivalent tree chars)
pos = line.find(marker)
indent = (pos // 4) + 1
clean_line = line.split(marker, 1)[1]
found_marker = True
break
if not found_marker:
if trimmed.startswith(("src/notebooklm", "tests")):
indent = 0
clean_line = trimmed
else:
continue
# Remove comments
if " # " in clean_line:
clean_line = clean_line.split(" # ", 1)[0]
clean_line = clean_line.strip().rstrip("/")
if not clean_line:
continue
# Manage the stack for tree traversal
while stack and stack[-1][0] >= indent:
stack.pop()
stack.append((indent, clean_line))
full_path = "/".join(segment for _, segment in stack)
if full_path.startswith(_DOCUMENTED_ROOTS):
paths.append(full_path)
return sorted(set(paths))
def _repository_structure_section(text: str) -> str:
section = _section_after_heading(text, "### Repository Structure")
next_heading = re.search(r"^\s*##\s+", section, flags=re.MULTILINE)
if next_heading is not None:
section = section[: next_heading.start()]
return section
def _extract_intentional_omissions(text: str) -> dict[str, str]:
"""Return intentionally omitted ``src/notebooklm`` paths and their reasons."""
omissions: dict[str, str] = {}
for line in _intentional_omissions_section(text).splitlines():
if "`src/notebooklm/" not in line:
continue
match = _IGNORED_PATH_RE.match(line)
if match is None:
continue
path = match.group("path").rstrip("/")
reason = match.group("reason").strip()
if reason:
omissions[path] = reason
return omissions
def _extract_unreasoned_omissions(text: str) -> list[str]:
"""Return omission bullets that mention a path but do not provide a reason."""
unreasoned: list[str] = []
for line in _intentional_omissions_section(text).splitlines():
if _OMISSION_PATH_RE.match(line) and _IGNORED_PATH_RE.match(line) is None:
unreasoned.append(line.strip())
return unreasoned
def _extract_omission_bullet_path(line: str) -> str | None:
match = _OMISSION_PATH_RE.match(line)
if match is None:
return None
return match.group("path").rstrip("/")
def _intentional_omissions_section(text: str) -> str:
section = _section_after_heading(text, _OMISSIONS_HEADING)
next_heading = re.search(r"^\s*###\s+", section, flags=re.MULTILINE)
if next_heading is not None:
section = section[: next_heading.start()]
return section
def _section_after_heading(text: str, heading: str) -> str:
match = re.search(rf"^\s*{re.escape(heading)}\s*$", text, flags=re.MULTILINE)
if match is None:
return ""
return text[match.end() :]
def _top_level_notebooklm_modules(repo_root: Path) -> list[str]:
"""Return every ``src/notebooklm`` Python module and subpackage.
Recurses through subpackages so that subpackage members (e.g.
``_auth/tokens.py``, ``rpc/overrides.py``) must be documented too — not
just direct top-level modules. ``__init__.py`` package markers are
excluded from the required set; the enclosing package directory stands in
for them.
"""
package_root = repo_root / "src" / "notebooklm"
if not package_root.is_dir():
return []
paths: list[str] = []
for child in package_root.rglob("*"):
if "__pycache__" in child.parts:
continue
is_package = child.is_dir() and (child / "__init__.py").is_file()
is_module = child.is_file() and child.suffix == ".py" and child.name != "__init__.py"
if is_package or is_module:
paths.append(child.relative_to(repo_root).as_posix())
return sorted(paths)
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--claude-md", default="docs/architecture.md")
ap.add_argument("--repo-root", default=".")
args = ap.parse_args(argv)
claude = Path(args.claude_md)
if not claude.is_file():
print(f"Structure doc not found: {claude}", file=sys.stderr)
return 2
repo_root = Path(args.repo_root).resolve()
text = _repository_structure_section(claude.read_text(encoding="utf-8"))
paths = _extract_paths(text)
omissions = _extract_intentional_omissions(text)
unreasoned_omissions = _extract_unreasoned_omissions(text)
unreasoned_paths = {
path for line in unreasoned_omissions if (path := _extract_omission_bullet_path(line))
}
missing = [p for p in paths if not (repo_root / p).exists()]
top_level_modules = _top_level_notebooklm_modules(repo_root)
undocumented = [
p
for p in top_level_modules
if p not in paths and p not in omissions and p not in unreasoned_paths
]
stale_omissions = [p for p in omissions if not (repo_root / p).exists()]
if missing or undocumented or stale_omissions or unreasoned_omissions:
if missing:
print("Stale structure-doc path references:", file=sys.stderr)
for p in missing:
print(f" {p}", file=sys.stderr)
if undocumented:
print("Undocumented src/notebooklm modules/packages:", file=sys.stderr)
for p in undocumented:
print(f" {p}", file=sys.stderr)
if stale_omissions:
print("Stale structure-doc intentional omissions:", file=sys.stderr)
for p in stale_omissions:
print(f" {p}", file=sys.stderr)
if unreasoned_omissions:
print("Intentional omissions without parseable reasons:", file=sys.stderr)
for line in unreasoned_omissions:
print(f" {line}", file=sys.stderr)
return 1
print(
f"OK: {len(paths)} documented paths resolve; "
f"{len(top_level_modules)} modules/packages "
f"are documented or intentionally omitted"
)
return 0
if __name__ == "__main__":
sys.exit(main())