Spaces:
Sleeping
Sleeping
| #!/usr/bin/env python3 | |
| # Copyright (c) 2025-2026, RTE (https://www.rte-france.com) | |
| # This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. | |
| # If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, | |
| # you can obtain one at http://mozilla.org/MPL/2.0/. | |
| # SPDX-License-Identifier: MPL-2.0 | |
| """D9 — docs as a checked artifact. | |
| The hand-maintained ``CLAUDE.md`` inventory docs (root + per-subtree) carry an | |
| elaborate file tree and hundreds of ``path/like/this.py`` references. They drift: | |
| files get renamed / removed and the prose keeps pointing at the old name, and | |
| ``file.py:352`` line-number anchors rot on the very next edit. This gate keeps | |
| that inventory layer honest with two checks: | |
| A. **Referenced-path existence.** Every backtick-quoted, directory-qualified | |
| path with a source/asset extension must resolve to a real file (under any | |
| sensible base dir — the docs write paths relative to their own subtree), | |
| UNLESS it is a known generated/runtime artifact or is referenced *as | |
| removed* (its line says "removed / former / renamed / superseded / …"). | |
| B. **No stale line-number anchors.** ``foo.py:352`` anchors are forbidden; | |
| the convention is a **symbol anchor** — name the function / class instead, | |
| which survives edits. (The review's own anchors had already rotted by | |
| hundreds of lines; this stops that class of drift recurring.) | |
| Usage:: | |
| python scripts/check_docs_tree.py # gate: non-zero on any finding | |
| python scripts/check_docs_tree.py --warn-only # report only, always exit 0 | |
| python scripts/check_docs_tree.py --json # machine-readable findings | |
| The module also exposes ``scan_docs(repo_root)`` returning structured findings so | |
| ``scripts/test_check_docs_tree.py`` can drive it hermetically on tmp fixtures. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import re | |
| import sys | |
| from dataclasses import dataclass, field | |
| from pathlib import Path | |
| REPO_ROOT = Path(__file__).resolve().parent.parent | |
| # The inventory docs this gate guards. Add a doc here to bring it under the gate. | |
| DOC_FILES = [ | |
| "CLAUDE.md", | |
| "expert_backend/CLAUDE.md", | |
| "expert_backend/tests/CLAUDE.md", | |
| "frontend/CLAUDE.md", | |
| ] | |
| # Paths in these docs are written relative to a contextual base (a frontend doc | |
| # says ``components/Foo.tsx`` meaning ``frontend/src/components/Foo.tsx``). A | |
| # reference is satisfied if it exists under ANY of these bases — we care that the | |
| # file EXISTS somewhere sensible, not that its relative form is canonical. | |
| BASE_DIRS = [ | |
| ".", | |
| "frontend", | |
| "frontend/src", | |
| "expert_backend", | |
| "expert_backend/tests", | |
| "scripts", | |
| "scripts/game_mode", | |
| "scripts/pypsa_eur", | |
| "docs", | |
| "data", | |
| "deploy", | |
| ] | |
| # A directory-qualified path with a source/asset extension, in backticks. | |
| PATH_RE = re.compile(r"`([A-Za-z0-9_./-]+/[A-Za-z0-9_./-]+\.(?:py|ts|tsx|css|json|html|md))`") | |
| # A rotting line-number anchor: ``file.py:352`` or ``file.tsx:310-324``. | |
| ANCHOR_RE = re.compile(r"\b([A-Za-z0-9_./-]+\.(?:py|ts|tsx|css|json|html)):(\d+(?:-\d+)?)\b") | |
| # Generated / runtime artifacts that legitimately do not exist in a fresh clone. | |
| # Matched as a suffix of the referenced path. | |
| GENERATED_SUFFIXES = ( | |
| "dist-standalone/standalone.html", | |
| "frontend/dist/index.html", | |
| "reports/code-quality.json", | |
| "reports/code-quality.md", | |
| ) | |
| # Generated / runtime path segments (matched anywhere in the reference). | |
| GENERATED_SEGMENTS = ( | |
| "Overflow_Graph/", | |
| "/dist/", | |
| "test-results/", | |
| "node_modules/", | |
| ) | |
| # When a reference's own line carries one of these words it is being described | |
| # AS gone (a removed module, a renamed file) — existence must NOT be asserted. | |
| REMOVED_RE = re.compile( | |
| r"\b(removed|former|formerly|deleted|decommission|decommissioned|renamed|" | |
| r"superseded|supersedes|no longer|legacy|replaced by|replaces the|frozen)\b", | |
| re.IGNORECASE, | |
| ) | |
| class Findings: | |
| missing_paths: list[tuple[str, int, str]] = field(default_factory=list) | |
| line_anchors: list[tuple[str, int, str]] = field(default_factory=list) | |
| def total(self) -> int: | |
| return len(self.missing_paths) + len(self.line_anchors) | |
| def as_dict(self) -> dict: | |
| return { | |
| "missing_paths": [ | |
| {"doc": d, "line": ln, "ref": r} for d, ln, r in self.missing_paths | |
| ], | |
| "line_anchors": [ | |
| {"doc": d, "line": ln, "anchor": a} for d, ln, a in self.line_anchors | |
| ], | |
| "total": self.total, | |
| } | |
| def _is_generated(ref: str) -> bool: | |
| if any(ref.endswith(sfx) for sfx in GENERATED_SUFFIXES): | |
| return True | |
| return any(seg in ref for seg in GENERATED_SEGMENTS) | |
| def _resolves(ref: str, repo_root: Path) -> bool: | |
| return any((repo_root / base / ref).exists() for base in BASE_DIRS) | |
| def scan_docs(repo_root: Path, doc_files: list[str] | None = None) -> Findings: | |
| """Scan the inventory docs and return structured findings. | |
| Pure and side-effect free (no printing) so tests can drive it on fixtures. | |
| """ | |
| findings = Findings() | |
| for doc in doc_files if doc_files is not None else DOC_FILES: | |
| doc_path = repo_root / doc | |
| if not doc_path.exists(): | |
| continue | |
| lines = doc_path.read_text(encoding="utf-8").splitlines() | |
| for lineno, line in enumerate(lines, 1): | |
| # Prose wraps, so a "removed / renamed / …" qualifier can sit on the | |
| # line before or after the reference itself — scan a ±1 window. | |
| window = " ".join(lines[max(0, lineno - 2):lineno + 1]) | |
| removed_context = bool(REMOVED_RE.search(window)) | |
| for m in PATH_RE.finditer(line): | |
| ref = m.group(1) | |
| if removed_context or _is_generated(ref): | |
| continue | |
| if not _resolves(ref, repo_root): | |
| findings.missing_paths.append((doc, lineno, ref)) | |
| for m in ANCHOR_RE.finditer(line): | |
| findings.line_anchors.append((doc, lineno, f"{m.group(1)}:{m.group(2)}")) | |
| return findings | |
| def _report(findings: Findings) -> None: | |
| if findings.missing_paths: | |
| print("Docs reference files that do not exist (rename/remove drift):") | |
| for doc, ln, ref in findings.missing_paths: | |
| print(f" - {doc}:{ln} → `{ref}`") | |
| print(" Fix: update the path, or note it as removed/generated on the same line.") | |
| if findings.line_anchors: | |
| print("Docs use stale line-number anchors (they rot on every edit):") | |
| for doc, ln, anchor in findings.line_anchors: | |
| print(f" - {doc}:{ln} → `{anchor}`") | |
| print(" Fix: replace `file.ext:NNN` with a SYMBOL anchor (name the " | |
| "function/class), e.g. `network_service.py` (`load_network`).") | |
| def main(argv: list[str] | None = None) -> int: | |
| ap = argparse.ArgumentParser(description="D9 docs-as-checked-artifact gate.") | |
| ap.add_argument("--warn-only", action="store_true", | |
| help="Report findings but always exit 0 (roll-in mode).") | |
| ap.add_argument("--json", action="store_true", help="Emit findings as JSON.") | |
| args = ap.parse_args(argv) | |
| findings = scan_docs(REPO_ROOT) | |
| if args.json: | |
| print(json.dumps(findings.as_dict(), indent=2)) | |
| else: | |
| _report(findings) | |
| if findings.total == 0: | |
| print("Docs-tree gate OK — every referenced path exists, no stale line anchors.") | |
| if findings.total and not args.warn_only: | |
| return 1 | |
| return 0 | |
| if __name__ == "__main__": | |
| sys.exit(main()) | |