#!/usr/bin/env python3 """Validate agent documentation and user docs for stale file references. Scans documentation files (``.devin/``, ``docs/``, ``README.md``) for: - References to files that no longer exist - References to deleted files (configurable blocklist) - References to deprecated patterns (configurable regex patterns) Configuration (``[tool.devx.check_agent_docs]`` in pyproject.toml): ``scan_dirs`` — directories to scan for docs (default: ``[".devin", "docs"]``) ``scan_files`` — specific files to scan (default: ``["README.md", "README.rst"]``) ``scan_extensions`` — file extensions to scan (default: ``[".md", ".yml", ".yaml"]``) ``excluded_paths`` — paths to exclude from scanning (default: ``["docs/retrospectives"]``) ``deleted_files`` — list of file paths that should never be referenced ``deprecated_patterns`` — list of regex patterns for deprecated references ``legitimate_indicators`` — substrings that indicate a legitimate reference to a deprecated pattern ``repo_path_prefixes`` — path prefixes that indicate a repo-relative reference (default: ``["ansible/", "scripts/", "tofu/", ".devin/", "src/"]``) ``min_path_ref_length`` — minimum length for a path reference to be checked (default: 5) Usage:: python3 -m devx.tools.check_agent_docs """ from __future__ import annotations import contextlib import re from pathlib import Path import click from devx.config import _load_pyproject_devx from devx.i18n import _ MIN_PATH_REF_LENGTH_DEFAULT = 5 # Pattern that matches file path references in markdown or code FILE_REF_RE = re.compile( r"(?:`|\")?" r"([\w\-./]+(?:\.[a-zA-Z0-9]+))" r"(?:`|\))?" ) DEFAULT_SCAN_DIRS = [".devin", "docs"] DEFAULT_SCAN_FILES = ["README.md", "README.rst"] DEFAULT_SCAN_EXTENSIONS = [".md", ".yml", ".yaml"] DEFAULT_EXCLUDED_PATHS = ["docs/retrospectives"] DEFAULT_REPO_PATH_PREFIXES = ["ansible/", "scripts/", "tofu/", ".devin/", "src/"] def _load_config() -> dict[str, object]: """Load check_agent_docs configuration from pyproject.toml.""" devx_cfg = _load_pyproject_devx() cfg_raw = devx_cfg.get("check_agent_docs", {}) if not isinstance(cfg_raw, dict): return {} return cfg_raw # type: ignore[return-value] def _should_skip(path: Path, excluded_paths: list[str], repo_root: Path) -> bool: """Check if a path should be excluded from scanning.""" try: rel = str(path.relative_to(repo_root)) except ValueError: return False return any(excluded in rel for excluded in excluded_paths) def _is_legitimate_ref(line: str, legitimate_indicators: list[str]) -> bool: """Check if a line contains a legitimate reference to a deprecated pattern.""" line_lower = line.lower() return any(legit.lower() in line_lower for legit in legitimate_indicators) def _collect_doc_files( repo_root: Path, scan_dirs: list[str], scan_files: list[str], scan_extensions: list[str], excluded_paths: list[str], ) -> list[Path]: """Collect all documentation files to scan.""" files: list[Path] = [] for scan_dir_name in scan_dirs: scan_dir = repo_root / scan_dir_name if not scan_dir.exists(): continue for ext in scan_extensions: for path in scan_dir.glob(f"**/*{ext}"): if not _should_skip(path, excluded_paths, repo_root): files.append(path) for readme_name in scan_files: path = repo_root / readme_name if path.exists() and not _should_skip(path, excluded_paths, repo_root): files.append(path) # Deduplicate while preserving order seen: set[Path] = set() unique: list[Path] = [] for f in files: if f not in seen: seen.add(f) unique.append(f) return unique def _check_file( path: Path, repo_root: Path, deleted_files: set[str], deprecated_patterns: list[re.Pattern[str]], legitimate_indicators: list[str], repo_path_prefixes: list[str], min_path_ref_length: int, skip_ref_prefixes: list[str], ) -> list[str]: """Check a single file for stale references.""" issues: list[str] = [] rel_path = path.relative_to(repo_root) try: content = path.read_text(encoding="utf-8") except UnicodeDecodeError: return issues for lineno, line in enumerate(content.splitlines(), start=1): # Check for deleted file references for deleted in deleted_files: if deleted in line: issues.append(f"{rel_path}:{lineno}: references deleted file '{deleted}'") # Check for deprecated pattern references for pattern in deprecated_patterns: if pattern.search(line) and not _is_legitimate_ref(line, legitimate_indicators): issues.append(f"{rel_path}:{lineno}: matches deprecated pattern '{pattern.pattern}'") # Check for references to files that don't exist for match in FILE_REF_RE.finditer(line): ref = match.group(1) # Skip URLs, bare words, and short strings if "/" not in ref or len(ref) < min_path_ref_length: continue # Only check references that look like repo paths if not any(ref.startswith(prefix) for prefix in repo_path_prefixes): continue # Skip references matching configured skip prefixes (e.g. aspirational test files) if any(ref.startswith(prefix) for prefix in skip_ref_prefixes): continue candidate = repo_root / ref if not candidate.exists(): issues.append(f"{rel_path}:{lineno}: references non-existent file '{ref}'") return issues @click.command() def cli() -> None: """Validate agent documentation and user docs for stale file references.""" repo_root = Path.cwd() cfg = _load_config() scan_dirs_raw = cfg.get("scan_dirs") scan_dirs: list[str] = [str(d) for d in scan_dirs_raw] if isinstance(scan_dirs_raw, list) else DEFAULT_SCAN_DIRS scan_files_raw = cfg.get("scan_files") scan_files: list[str] = [str(d) for d in scan_files_raw] if isinstance(scan_files_raw, list) else DEFAULT_SCAN_FILES scan_ext_raw = cfg.get("scan_extensions") scan_extensions: list[str] = ( [str(d) for d in scan_ext_raw] if isinstance(scan_ext_raw, list) else DEFAULT_SCAN_EXTENSIONS ) excluded_raw = cfg.get("excluded_paths") excluded_paths: list[str] = ( [str(d) for d in excluded_raw] if isinstance(excluded_raw, list) else DEFAULT_EXCLUDED_PATHS ) prefixes_raw = cfg.get("repo_path_prefixes") repo_path_prefixes: list[str] = ( [str(d) for d in prefixes_raw] if isinstance(prefixes_raw, list) else DEFAULT_REPO_PATH_PREFIXES ) min_len_raw = cfg.get("min_path_ref_length") min_path_ref_length: int = int(min_len_raw) if isinstance(min_len_raw, int) else MIN_PATH_REF_LENGTH_DEFAULT skip_prefixes_raw = cfg.get("skip_ref_prefixes", []) skip_ref_prefixes: list[str] = [str(d) for d in skip_prefixes_raw] if isinstance(skip_prefixes_raw, list) else [] deleted_files: set[str] = set() deleted_raw = cfg.get("deleted_files", []) if isinstance(deleted_raw, list): deleted_files = {str(d) for d in deleted_raw} deprecated_patterns: list[re.Pattern[str]] = [] deprecated_raw = cfg.get("deprecated_patterns", []) if isinstance(deprecated_raw, list): for pattern_str in deprecated_raw: if isinstance(pattern_str, str): with contextlib.suppress(re.error): deprecated_patterns.append(re.compile(pattern_str)) legitimate_indicators: list[str] = [] legit_raw = cfg.get("legitimate_indicators", []) if isinstance(legit_raw, list): legitimate_indicators = [str(s) for s in legit_raw] files = _collect_doc_files(repo_root, scan_dirs, scan_files, scan_extensions, excluded_paths) all_issues: list[str] = [] for path in sorted(files): issues = _check_file( path, repo_root, deleted_files, deprecated_patterns, legitimate_indicators, repo_path_prefixes, min_path_ref_length, skip_ref_prefixes, ) all_issues.extend(issues) if all_issues: click.echo(f"[check_agent_docs] Found {len(all_issues)} issue(s):\n", err=True) for issue in all_issues: click.echo(issue, err=True) click.echo( f"\n[check_agent_docs] FAILED: {len(all_issues)} stale reference(s)", err=True, ) raise click.ClickException(_("Found {count} stale documentation reference(s)", count=len(all_issues))) click.echo(_("[check_agent_docs] Passed: scanned {count} file(s), no stale references", count=len(files))) if __name__ == "__main__": # pragma: no cover cli() # pragma: no cover