Public Access
Post-merge / detect-type (push) Successful in 38s
Post-merge / validate-commit-msg (push) Successful in 45s
Post-merge / sync-wiki (push) Successful in 51s
Post-merge / release (push) Successful in 57s
Post-merge / badges (push) Successful in 59s
Post-merge / configure-repo (push) Successful in 1m1s
Post-merge / vikunja (push) Successful in 1m22s
238 lines
8.8 KiB
Python
238 lines
8.8 KiB
Python
#!/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
|