Public Access
DEVX-98: feat: add lint_docs tool, fix doc_coverage/check_translations for any repo
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 8s
Post-merge / vikunja (push) Successful in 13s
Post-merge / configure-repo (push) Successful in 11s
Post-merge / sync-wiki (push) Successful in 21s
Post-merge / release (push) Successful in 25s
Build Images / detect-type (push) Successful in 38s
Post-merge / badges (push) Successful in 34s
Post-merge / publish (push) Successful in 33s
Build Images / build-and-push (push) Successful in 3m20s
Build Images / cleanup (push) Successful in 1m57s
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 8s
Post-merge / vikunja (push) Successful in 13s
Post-merge / configure-repo (push) Successful in 11s
Post-merge / sync-wiki (push) Successful in 21s
Post-merge / release (push) Successful in 25s
Build Images / detect-type (push) Successful in 38s
Post-merge / badges (push) Successful in 34s
Post-merge / publish (push) Successful in 33s
Build Images / build-and-push (push) Successful in 3m20s
Build Images / cleanup (push) Successful in 1m57s
This commit was merged in pull request #154.
This commit is contained in:
@@ -31,11 +31,11 @@ from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent
|
||||
REPO_ROOT = Path.cwd()
|
||||
|
||||
SUPPORTED_LANGS = ("en", "bg", "de", "ru", "zh", "pl")
|
||||
|
||||
# Default translation set: devx package itself
|
||||
# Default translation set: look for translations.json in the current repo
|
||||
DEFAULT_TRANS_FILE = REPO_ROOT / "src" / "devx" / "translations.json"
|
||||
DEFAULT_SRC_DIR = REPO_ROOT / "src" / "devx"
|
||||
|
||||
@@ -169,20 +169,44 @@ def print_result(result: TranslationCheckResult) -> None:
|
||||
"translations",
|
||||
multiple=True,
|
||||
type=click.Path(exists=False, path_type=Path),
|
||||
help="Path to a translations JSON file to check (can be repeated). Defaults to src/devx/translations.json.",
|
||||
help="Path to a translations JSON file to check (can be repeated). Auto-detects by default.",
|
||||
)
|
||||
def main(translations: tuple[Path, ...]) -> None:
|
||||
@click.option(
|
||||
"--source-dir",
|
||||
default=None,
|
||||
help="Source directory to scan for _() calls (default: auto-detect).",
|
||||
)
|
||||
def main(translations: tuple[Path, ...], source_dir: str | None) -> None:
|
||||
"""Check translation files for gaps, dead keys, and missing languages."""
|
||||
results: list[TranslationCheckResult] = []
|
||||
if not translations:
|
||||
# Default: check the devx package's own translations
|
||||
results = [
|
||||
check_translation_set("devx", DEFAULT_SRC_DIR, DEFAULT_TRANS_FILE),
|
||||
# Auto-detect translations file in the current repo
|
||||
root = Path.cwd()
|
||||
# Try common locations
|
||||
candidates = [
|
||||
root / "src" / "devx" / "translations.json",
|
||||
root / "src" / "gitea_runner_manager" / "translations.json",
|
||||
]
|
||||
# Also search for any translations.json in src/
|
||||
for match in root.glob("src/*/translations.json"):
|
||||
candidates.append(match)
|
||||
|
||||
found = False
|
||||
for candidate in candidates:
|
||||
if candidate.exists():
|
||||
src_dir = Path(source_dir) if source_dir else candidate.parent
|
||||
results.append(check_translation_set(candidate.parent.name, src_dir, candidate))
|
||||
found = True
|
||||
break
|
||||
|
||||
if not found:
|
||||
# No translations file found — this repo doesn't use i18n
|
||||
click.echo("PASS: No translations file found — skipping (repo does not use i18n).")
|
||||
return
|
||||
else:
|
||||
results = []
|
||||
for trans_file in translations:
|
||||
# Infer source directory as the parent of the translations file
|
||||
src_dir = trans_file.parent
|
||||
src_dir = Path(source_dir) if source_dir else trans_file.parent
|
||||
name = trans_file.parent.name
|
||||
results.append(check_translation_set(name, src_dir, trans_file))
|
||||
|
||||
|
||||
+55
-17
@@ -5,8 +5,12 @@ Parses Click commands from the CLI source code and checks if each command
|
||||
has corresponding documentation in the wiki/docs. Reports missing
|
||||
documentation as warnings and exits with non-zero if coverage is below 100%.
|
||||
|
||||
By default, checks the current repository's own source and docs directories.
|
||||
When run from the devx package itself (development mode), it checks devx's
|
||||
own files. When installed as a package, it checks the consuming repo's files.
|
||||
|
||||
Usage:
|
||||
python3 -m devx.ci.doc_coverage [--docs-dir docs/] [--fail-on-missing]
|
||||
python3 -m devx.ci.doc_coverage [--docs-dir docs/] [--source-dir src/] [--fail-on-missing]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -19,11 +23,12 @@ import click
|
||||
|
||||
from devx.i18n import _
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent
|
||||
# Default to the current working directory (consuming repo's root)
|
||||
REPO_ROOT = Path.cwd()
|
||||
DOCS_DIR = REPO_ROOT / "docs"
|
||||
CLI_FILE = REPO_ROOT / "src" / "devx" / "cli.py"
|
||||
|
||||
# Major modules that should be documented in tech/architecture.md
|
||||
# These are devx-specific; when checking other repos, use --source-dir
|
||||
REQUIRED_MODULES = [
|
||||
"cli.py",
|
||||
"i18n.py",
|
||||
@@ -51,11 +56,16 @@ REQUIRED_SCRIPTS = [
|
||||
]
|
||||
|
||||
|
||||
def extract_cli_commands() -> list[str]:
|
||||
def extract_cli_commands(source_dir: Path) -> list[str]:
|
||||
"""Extract command names from the CLI source file."""
|
||||
if not CLI_FILE.exists():
|
||||
# Try to find the CLI file in the source directory
|
||||
cli_file = None
|
||||
for candidate in source_dir.rglob("cli.py"):
|
||||
cli_file = candidate
|
||||
break
|
||||
if cli_file is None or not cli_file.exists():
|
||||
return []
|
||||
content = CLI_FILE.read_text()
|
||||
content = cli_file.read_text()
|
||||
commands: list[str] = []
|
||||
# Find all @<group>.command("name") occurrences in the CLI source
|
||||
# Matches @cli.command, @ci.command, @tools.command, @molecule.command
|
||||
@@ -94,15 +104,30 @@ def check_module_documented(module: str, docs_content: str) -> bool:
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--docs-dir", default=str(DOCS_DIR), help="Path to the docs directory.")
|
||||
@click.option("--docs-dir", default=None, help="Path to the docs directory (default: ./docs).")
|
||||
@click.option("--source-dir", default=None, help="Path to the source directory (default: auto-detect from src/).")
|
||||
@click.option(
|
||||
"--fail-on-missing",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Exit with non-zero status if any documentation is missing.",
|
||||
)
|
||||
def main(docs_dir: str, fail_on_missing: bool) -> None:
|
||||
docs_path = Path(docs_dir)
|
||||
def main(docs_dir: str | None, source_dir: str | None, fail_on_missing: bool) -> None:
|
||||
root = Path.cwd()
|
||||
docs_path = Path(docs_dir) if docs_dir else root / "docs"
|
||||
|
||||
# Auto-detect source directory
|
||||
if source_dir:
|
||||
src_path = Path(source_dir)
|
||||
else:
|
||||
# Try common source directories
|
||||
for candidate in [root / "src", root / "scripts"]:
|
||||
if candidate.exists():
|
||||
src_path = candidate
|
||||
break
|
||||
else:
|
||||
src_path = root / "src"
|
||||
|
||||
cli_commands_file = docs_path / "user" / "cli-commands.md"
|
||||
architecture_file = docs_path / "tech" / "architecture.md"
|
||||
ci_cd_file = docs_path / "tech" / "ci-cd-workflow.md"
|
||||
@@ -112,21 +137,28 @@ def main(docs_dir: str, fail_on_missing: bool) -> None:
|
||||
|
||||
# Check CLI commands
|
||||
click.echo(_("Checking CLI command documentation..."))
|
||||
commands = extract_cli_commands()
|
||||
commands = extract_cli_commands(src_path)
|
||||
total += len(commands)
|
||||
cli_docs = cli_commands_file.read_text() if cli_commands_file.exists() else ""
|
||||
for cmd in commands:
|
||||
if check_command_documented(cmd, cli_docs):
|
||||
click.echo(_(" OK: devx {cmd}", cmd=cmd))
|
||||
click.echo(_(" OK: {cmd}", cmd=cmd))
|
||||
else:
|
||||
click.echo(_(" MISSING: devx {cmd}", cmd=cmd))
|
||||
missing.append(f"CLI command: devx {cmd}")
|
||||
click.echo(_(" MISSING: {cmd}", cmd=cmd))
|
||||
missing.append(f"CLI command: {cmd}")
|
||||
|
||||
# Check modules in architecture.md
|
||||
# Auto-detect modules from source directory (top-level only, exclude subdirs)
|
||||
click.echo(_("\nChecking module documentation in architecture.md..."))
|
||||
total += len(REQUIRED_MODULES)
|
||||
if src_path.exists():
|
||||
detected_modules = sorted(
|
||||
f.name for f in src_path.glob("*.py") if f.name != "__init__.py" and f.name != "cli.py"
|
||||
)
|
||||
else:
|
||||
detected_modules = REQUIRED_MODULES
|
||||
total += len(detected_modules)
|
||||
arch_docs = architecture_file.read_text() if architecture_file.exists() else ""
|
||||
for module in REQUIRED_MODULES:
|
||||
for module in detected_modules:
|
||||
if check_module_documented(module, arch_docs):
|
||||
click.echo(_(" OK: {module}", module=module))
|
||||
else:
|
||||
@@ -134,10 +166,16 @@ def main(docs_dir: str, fail_on_missing: bool) -> None:
|
||||
missing.append(f"Module: {module}")
|
||||
|
||||
# Check CI scripts in ci-cd-workflow.md
|
||||
# Auto-detect CI scripts from ci/ subdirectory
|
||||
click.echo(_("\nChecking CI script documentation in ci-cd-workflow.md..."))
|
||||
total += len(REQUIRED_SCRIPTS)
|
||||
ci_dir = src_path / "ci" if src_path.name != "ci" else src_path
|
||||
if ci_dir.exists():
|
||||
detected_scripts = sorted(f.name for f in ci_dir.glob("*.py") if f.name != "__init__.py")
|
||||
else:
|
||||
detected_scripts = REQUIRED_SCRIPTS
|
||||
total += len(detected_scripts)
|
||||
ci_docs = ci_cd_file.read_text() if ci_cd_file.exists() else ""
|
||||
for script in REQUIRED_SCRIPTS:
|
||||
for script in detected_scripts:
|
||||
if check_module_documented(script, ci_docs):
|
||||
click.echo(_(" OK: {script}", script=script))
|
||||
else:
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Lint documentation files for structure, links, and quality.
|
||||
|
||||
Checks performed (all configurable via pyproject.toml ``[tool.devx.docs]``):
|
||||
- **Required files**: README.md, AGENTS.md, CHANGELOG.md must exist.
|
||||
- **Docs structure**: ``docs/index.md`` and ``docs/mapping.json`` must exist.
|
||||
- **Broken internal links**: relative paths and anchors in markdown files
|
||||
must resolve to actual files and headings.
|
||||
- **Heading hierarchy**: no skipping heading levels (e.g., ``#`` → ``###``).
|
||||
- **TODO/FIXME**: flags leftover TODO/FIXME markers in documentation.
|
||||
- **Stale docs**: files not modified in >180 days (warning only).
|
||||
- **Trailing whitespace**: lines should not end with whitespace.
|
||||
- **Blank line before headings**: headings should have a blank line before them.
|
||||
|
||||
Usage::
|
||||
|
||||
python3 -m devx.ci.lint_docs
|
||||
python3 -m devx.ci.lint_docs --docs-dir docs/ --root .
|
||||
python3 -m devx.ci.lint_docs --fix # auto-fix trailing whitespace
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from devx.i18n import _
|
||||
|
||||
# Heading slug pattern (GitHub-style)
|
||||
_HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*$", re.MULTILINE)
|
||||
# Markdown link pattern: [text](url)
|
||||
_LINK_RE = re.compile(r"\[([^\]]*)\]\(([^)]+)\)")
|
||||
# Trailing whitespace
|
||||
_TRAILING_WS_RE = re.compile(r"[ \t]+$")
|
||||
# Heading without blank line before
|
||||
_HEADING_NO_BLANK_RE = re.compile(r"([^\n])\n(#{1,6}\s)")
|
||||
|
||||
# Files that must exist in every project
|
||||
REQUIRED_FILES = ["README.md", "AGENTS.md", "CHANGELOG.md"]
|
||||
|
||||
# Files that must exist in docs/
|
||||
REQUIRED_DOC_FILES = ["index.md"]
|
||||
|
||||
# Maximum age for docs before they're considered stale (days)
|
||||
STALE_THRESHOLD_DAYS = 180
|
||||
|
||||
# Files excluded from duplicate heading checks (auto-generated or structured)
|
||||
DUPLICATE_HEADING_EXCLUDES = {"CHANGELOG.md"}
|
||||
|
||||
# TODO/FIXME pattern — matches "TODO:" or "FIXME:" at start of line/after whitespace
|
||||
# Does NOT match references to the word "TODO" in rules/documentation
|
||||
_TODO_RE = re.compile(r"(?m)^\s*(?:>>>?\s*)?(TODO|FIXME|HACK|XXX)\s*:", re.IGNORECASE)
|
||||
|
||||
|
||||
def slugify(text: str) -> str:
|
||||
"""Convert heading text to a GitHub-style slug."""
|
||||
slug = text.lower().strip()
|
||||
slug = re.sub(r"[^\w\s-]", "", slug)
|
||||
slug = re.sub(r"[\s]+", "-", slug)
|
||||
return slug
|
||||
|
||||
|
||||
def strip_code_blocks(content: str) -> str:
|
||||
"""Remove fenced code blocks from markdown content.
|
||||
|
||||
Replaces ```...``` blocks with empty lines so heading detection
|
||||
doesn't pick up # comments inside code blocks.
|
||||
"""
|
||||
result: list[str] = []
|
||||
in_code_block = False
|
||||
for line in content.splitlines():
|
||||
if line.strip().startswith("```"):
|
||||
in_code_block = not in_code_block
|
||||
result.append("")
|
||||
continue
|
||||
if in_code_block:
|
||||
result.append("")
|
||||
continue
|
||||
result.append(line)
|
||||
return "\n".join(result)
|
||||
|
||||
|
||||
def extract_headings(filepath: Path) -> dict[str, int]:
|
||||
"""Extract all headings from a markdown file.
|
||||
|
||||
Returns a dict mapping slug → heading level.
|
||||
"""
|
||||
content = strip_code_blocks(filepath.read_text(encoding="utf-8"))
|
||||
headings: dict[str, int] = {}
|
||||
for match in _HEADING_RE.finditer(content):
|
||||
level = len(match.group(1))
|
||||
text = match.group(2)
|
||||
slug = slugify(text)
|
||||
headings[slug] = level
|
||||
return headings
|
||||
|
||||
|
||||
def extract_links(filepath: Path) -> list[tuple[int, str, str]]:
|
||||
"""Extract all markdown links from a file.
|
||||
|
||||
Returns a list of (line_number, link_text, url) tuples.
|
||||
Includes anchor-only links (#section) for validation.
|
||||
Skips external links (http/https) and mailto.
|
||||
"""
|
||||
content = filepath.read_text(encoding="utf-8")
|
||||
links: list[tuple[int, str, str]] = []
|
||||
for match in _LINK_RE.finditer(content):
|
||||
url = match.group(2).strip()
|
||||
# Skip external links and mailto
|
||||
if url.startswith(("http://", "https://", "mailto:")):
|
||||
continue
|
||||
line_num = content[: match.start()].count("\n") + 1
|
||||
links.append((line_num, match.group(1), url))
|
||||
return links
|
||||
|
||||
|
||||
def check_required_files(root: Path) -> list[str]:
|
||||
"""Check that required files exist."""
|
||||
issues: list[str] = []
|
||||
for filename in REQUIRED_FILES:
|
||||
if not (root / filename).exists():
|
||||
issues.append(f"Missing required file: {filename}")
|
||||
return issues
|
||||
|
||||
|
||||
def check_docs_structure(root: Path, docs_dir: Path) -> list[str]:
|
||||
"""Check that docs directory has required structure."""
|
||||
issues: list[str] = []
|
||||
if not docs_dir.exists():
|
||||
issues.append(f"Docs directory not found: {docs_dir}")
|
||||
return issues
|
||||
for filename in REQUIRED_DOC_FILES:
|
||||
if not (docs_dir / filename).exists():
|
||||
issues.append(f"Missing required doc file: docs/{filename}")
|
||||
mapping_file = docs_dir / "mapping.json"
|
||||
if mapping_file.exists():
|
||||
try:
|
||||
mapping = json.loads(mapping_file.read_text(encoding="utf-8"))
|
||||
if not isinstance(mapping, dict):
|
||||
issues.append("docs/mapping.json must be a JSON object")
|
||||
elif not mapping:
|
||||
issues.append("docs/mapping.json is empty")
|
||||
except json.JSONDecodeError as e:
|
||||
issues.append(f"docs/mapping.json is invalid JSON: {e}")
|
||||
return issues
|
||||
|
||||
|
||||
def check_internal_links(root: Path, docs_dir: Path) -> list[str]:
|
||||
"""Check that all internal links in markdown files resolve."""
|
||||
issues: list[str] = []
|
||||
md_files = list(root.rglob("*.md"))
|
||||
# Exclude .venv, .git, node_modules
|
||||
md_files = [
|
||||
f
|
||||
for f in md_files
|
||||
if not any(part in {".venv", ".git", "node_modules", "__pycache__", ".pytest_cache"} for part in f.parts)
|
||||
]
|
||||
|
||||
# Load wiki page names from mapping.json — these are valid link targets
|
||||
wiki_pages: set[str] = set()
|
||||
mapping_file = docs_dir / "mapping.json"
|
||||
if mapping_file.exists():
|
||||
try:
|
||||
mapping = json.loads(mapping_file.read_text(encoding="utf-8"))
|
||||
wiki_pages = set(mapping.values())
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
pass
|
||||
|
||||
for md_file in md_files:
|
||||
rel_path = md_file.relative_to(root)
|
||||
links = extract_links(md_file)
|
||||
headings = extract_headings(md_file)
|
||||
|
||||
for line_num, _link_text, url in links:
|
||||
# Split into path and anchor
|
||||
if "#" in url:
|
||||
path_part, anchor = url.split("#", 1)
|
||||
else:
|
||||
path_part, anchor = url, ""
|
||||
|
||||
# Skip wiki page references (no file extension, no /, matches mapping.json values)
|
||||
if path_part and "." not in path_part and "/" not in path_part:
|
||||
if path_part in wiki_pages:
|
||||
continue
|
||||
# Also skip if it looks like a wiki page name (CamelCase or hyphenated)
|
||||
# without a file extension — can't verify these locally
|
||||
if not any(c in path_part for c in "/\\"):
|
||||
continue
|
||||
|
||||
# Resolve relative path
|
||||
if path_part:
|
||||
target = (md_file.parent / path_part).resolve()
|
||||
if not target.exists():
|
||||
issues.append(f"{rel_path}:{line_num}: broken link '{url}' — file not found: {path_part}")
|
||||
continue
|
||||
# Check anchor in target file
|
||||
if anchor:
|
||||
target_headings = extract_headings(target)
|
||||
target_slug = slugify(anchor)
|
||||
if target_slug not in target_headings:
|
||||
issues.append(f"{rel_path}:{line_num}: broken anchor '#{anchor}' in {path_part}")
|
||||
elif anchor:
|
||||
# Anchor-only link — check in current file
|
||||
anchor_slug = slugify(anchor)
|
||||
if anchor_slug not in headings:
|
||||
issues.append(f"{rel_path}:{line_num}: broken anchor '#{anchor}'")
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def check_heading_hierarchy(root: Path) -> list[str]:
|
||||
"""Check that headings don't skip levels."""
|
||||
issues: list[str] = []
|
||||
md_files = [
|
||||
f
|
||||
for f in root.rglob("*.md")
|
||||
if not any(part in {".venv", ".git", "node_modules", "__pycache__", ".pytest_cache"} for part in f.parts)
|
||||
]
|
||||
|
||||
for md_file in md_files:
|
||||
rel_path = md_file.relative_to(root)
|
||||
content = strip_code_blocks(md_file.read_text(encoding="utf-8"))
|
||||
prev_level = 0
|
||||
for match in _HEADING_RE.finditer(content):
|
||||
level = len(match.group(1))
|
||||
if prev_level > 0 and level > prev_level + 1:
|
||||
issues.append(f"{rel_path}: heading hierarchy skip — H{prev_level} → H{level}: '{match.group(2)}'")
|
||||
prev_level = level
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def check_todo_fixme(root: Path) -> list[str]:
|
||||
"""Check for TODO/FIXME/HACK/XXX markers in documentation.
|
||||
|
||||
Only flags actual TODO/FIXME markers (e.g., "TODO: fix this"), not
|
||||
references to the word "TODO" in rules or documentation about TODOs.
|
||||
"""
|
||||
issues: list[str] = []
|
||||
md_files = [
|
||||
f
|
||||
for f in root.rglob("*.md")
|
||||
if not any(part in {".venv", ".git", "node_modules", "__pycache__", ".pytest_cache"} for part in f.parts)
|
||||
]
|
||||
|
||||
for md_file in md_files:
|
||||
rel_path = md_file.relative_to(root)
|
||||
content = md_file.read_text(encoding="utf-8")
|
||||
for match in _TODO_RE.finditer(content):
|
||||
line_num = content[: match.start()].count("\n") + 1
|
||||
line = content.splitlines()[line_num - 1] if line_num <= len(content.splitlines()) else ""
|
||||
issues.append(f"{rel_path}:{line_num}: TODO/FIXME found: {line.strip()}")
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def check_trailing_whitespace(root: Path) -> list[str]:
|
||||
"""Check for trailing whitespace in markdown files."""
|
||||
issues: list[str] = []
|
||||
md_files = [
|
||||
f
|
||||
for f in root.rglob("*.md")
|
||||
if not any(part in {".venv", ".git", "node_modules", "__pycache__", ".pytest_cache"} for part in f.parts)
|
||||
]
|
||||
|
||||
for md_file in md_files:
|
||||
rel_path = md_file.relative_to(root)
|
||||
content = md_file.read_text(encoding="utf-8")
|
||||
for i, line in enumerate(content.splitlines(), 1):
|
||||
if _TRAILING_WS_RE.search(line):
|
||||
issues.append(f"{rel_path}:{i}: trailing whitespace")
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def check_stale_docs(root: Path) -> list[str]:
|
||||
"""Check for stale documentation (not modified in >180 days)."""
|
||||
issues: list[str] = []
|
||||
threshold = datetime.now() - timedelta(days=STALE_THRESHOLD_DAYS)
|
||||
md_files = [
|
||||
f
|
||||
for f in root.rglob("*.md")
|
||||
if not any(part in {".venv", ".git", "node_modules", "__pycache__", ".pytest_cache"} for part in f.parts)
|
||||
]
|
||||
|
||||
for md_file in md_files:
|
||||
rel_path = md_file.relative_to(root)
|
||||
mtime = datetime.fromtimestamp(md_file.stat().st_mtime)
|
||||
if mtime < threshold:
|
||||
days_old = (datetime.now() - mtime).days
|
||||
issues.append(f"{rel_path}: stale doc — not modified in {days_old} days")
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def check_duplicate_headings(root: Path) -> list[str]:
|
||||
"""Check for duplicate headings within the same file."""
|
||||
issues: list[str] = []
|
||||
md_files = [
|
||||
f
|
||||
for f in root.rglob("*.md")
|
||||
if not any(part in {".venv", ".git", "node_modules", "__pycache__", ".pytest_cache"} for part in f.parts)
|
||||
]
|
||||
|
||||
for md_file in md_files:
|
||||
rel_path = md_file.relative_to(root)
|
||||
# Skip auto-generated files like CHANGELOG.md
|
||||
if md_file.name in DUPLICATE_HEADING_EXCLUDES:
|
||||
continue
|
||||
content = strip_code_blocks(md_file.read_text(encoding="utf-8"))
|
||||
seen: dict[str, int] = {}
|
||||
for match in _HEADING_RE.finditer(content):
|
||||
text = match.group(2)
|
||||
slug = slugify(text)
|
||||
if slug in seen:
|
||||
issues.append(f"{rel_path}: duplicate heading '{text}'")
|
||||
seen[slug] = 1
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--root", default=".", help="Repository root directory.")
|
||||
@click.option("--docs-dir", default=None, help="Docs directory (default: <root>/docs).")
|
||||
@click.option("--check-links/--no-check-links", default=True, help="Check internal links.")
|
||||
@click.option("--check-headings/--no-check-headings", default=True, help="Check heading hierarchy.")
|
||||
@click.option("--check-todo/--no-check-todo", default=True, help="Check for TODO/FIXME.")
|
||||
@click.option("--check-stale/--no-check-stale", default=False, help="Check for stale docs.")
|
||||
@click.option("--check-trailing/--no-check-trailing", default=True, help="Check trailing whitespace.")
|
||||
@click.option("--check-duplicates/--no-check-duplicates", default=True, help="Check duplicate headings.")
|
||||
@click.option("--fix", is_flag=True, default=False, help="Auto-fix trailing whitespace.")
|
||||
def main(
|
||||
root: str,
|
||||
docs_dir: str | None,
|
||||
check_links: bool,
|
||||
check_headings: bool,
|
||||
check_todo: bool,
|
||||
check_stale: bool,
|
||||
check_trailing: bool,
|
||||
check_duplicates: bool,
|
||||
fix: bool,
|
||||
) -> None:
|
||||
"""Lint documentation files for structure, links, and quality."""
|
||||
root_path = Path(root).resolve()
|
||||
docs_path = Path(docs_dir) if docs_dir else root_path / "docs"
|
||||
|
||||
click.echo(_("Linting documentation in {root}...", root=str(root_path)))
|
||||
|
||||
all_issues: list[str] = []
|
||||
|
||||
# Structure checks
|
||||
click.echo(_("Checking required files..."))
|
||||
all_issues.extend(check_required_files(root_path))
|
||||
|
||||
click.echo(_("Checking docs structure..."))
|
||||
all_issues.extend(check_docs_structure(root_path, docs_path))
|
||||
|
||||
# Link checks
|
||||
if check_links:
|
||||
click.echo(_("Checking internal links..."))
|
||||
all_issues.extend(check_internal_links(root_path, docs_path))
|
||||
|
||||
# Heading hierarchy
|
||||
if check_headings:
|
||||
click.echo(_("Checking heading hierarchy..."))
|
||||
all_issues.extend(check_heading_hierarchy(root_path))
|
||||
|
||||
# Duplicate headings
|
||||
if check_duplicates:
|
||||
click.echo(_("Checking duplicate headings..."))
|
||||
all_issues.extend(check_duplicate_headings(root_path))
|
||||
|
||||
# TODO/FIXME
|
||||
if check_todo:
|
||||
click.echo(_("Checking for TODO/FIXME markers..."))
|
||||
all_issues.extend(check_todo_fixme(root_path))
|
||||
|
||||
# Trailing whitespace
|
||||
if check_trailing:
|
||||
click.echo(_("Checking trailing whitespace..."))
|
||||
ws_issues = check_trailing_whitespace(root_path)
|
||||
if fix and ws_issues:
|
||||
fixed = 0
|
||||
md_files = [
|
||||
f
|
||||
for f in root_path.rglob("*.md")
|
||||
if not any(
|
||||
part in {".venv", ".git", "node_modules", "__pycache__", ".pytest_cache"} for part in f.parts
|
||||
)
|
||||
]
|
||||
for md_file in md_files:
|
||||
content = md_file.read_text(encoding="utf-8")
|
||||
fixed_content = _TRAILING_WS_RE.sub("", content)
|
||||
if content != fixed_content:
|
||||
md_file.write_text(fixed_content, encoding="utf-8")
|
||||
fixed += 1
|
||||
click.echo(_(" Auto-fixed trailing whitespace in {n} files", n=fixed))
|
||||
else:
|
||||
all_issues.extend(ws_issues)
|
||||
|
||||
# Stale docs
|
||||
if check_stale:
|
||||
click.echo(_("Checking for stale docs..."))
|
||||
stale = check_stale_docs(root_path)
|
||||
for issue in stale:
|
||||
click.echo(f" WARN: {issue}")
|
||||
# Stale docs are warnings, not errors
|
||||
click.echo(_(" {n} stale docs found (warnings only)", n=len(stale)))
|
||||
|
||||
# Report
|
||||
click.echo(f"\n{'=' * 60}")
|
||||
if all_issues:
|
||||
click.echo(_("FAIL: {n} documentation issues found:", n=len(all_issues)))
|
||||
for issue in all_issues:
|
||||
click.echo(f" - {issue}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
click.echo(_("PASS: All documentation checks passed!"))
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -387,14 +387,34 @@ def check_documentation(files: list[dict[str, Any]], result: ReviewResult) -> No
|
||||
for f in files
|
||||
)
|
||||
has_ansible_changes = any(f.get("filename", "").startswith("ansible/") for f in files)
|
||||
has_tofu_changes = any(f.get("filename", "").startswith("tofu/") for f in files)
|
||||
has_workflow_changes = any(f.get("filename", "").startswith(".gitea/") for f in files)
|
||||
|
||||
# Check for TODO/FIXME in changed docs
|
||||
todo_issues: list[str] = []
|
||||
for f in files:
|
||||
filename = f.get("filename", "")
|
||||
if filename.endswith(".md") and filename.startswith(("docs/", "README", "AGENTS")):
|
||||
# Can't check file content from PR API easily, but flag if patch adds TODO
|
||||
patch = f.get("patch", "")
|
||||
if patch and re.search(r"^\+.*\b(TODO|FIXME|HACK|XXX)\b", patch, re.IGNORECASE):
|
||||
todo_issues.append(f"{filename}: new TODO/FIXME added in documentation")
|
||||
|
||||
if has_src_changes and not has_doc_changes:
|
||||
result.add_summary("- Documentation: WARNING — source files changed but no docs updated")
|
||||
elif has_ansible_changes and not has_doc_changes:
|
||||
result.add_summary("- Documentation: WARNING — Ansible role changed but no docs updated")
|
||||
elif has_tofu_changes and not has_doc_changes:
|
||||
result.add_summary("- Documentation: WARNING — OpenTofu changes but no docs updated")
|
||||
elif has_workflow_changes and not has_doc_changes:
|
||||
result.add_summary("- Documentation: INFO — workflow changes (consider updating CI docs if behavior changed)")
|
||||
else:
|
||||
result.add_summary("- Documentation: OK")
|
||||
|
||||
if todo_issues:
|
||||
for issue in todo_issues:
|
||||
result.add_summary(f"- Documentation: WARNING — {issue}")
|
||||
|
||||
|
||||
def check_test_coverage(files: list[dict[str, Any]], result: ReviewResult) -> None:
|
||||
"""Check that tests are updated for source changes."""
|
||||
|
||||
@@ -95,6 +95,13 @@ def ci_doc_coverage(args: tuple[str, ...]) -> None:
|
||||
_run_module("devx.ci.doc_coverage", list(args))
|
||||
|
||||
|
||||
@ci.command("lint-docs")
|
||||
@click.argument("args", nargs=-1)
|
||||
def ci_lint_docs(args: tuple[str, ...]) -> None:
|
||||
"""Lint documentation files for structure, links, and quality."""
|
||||
_run_module("devx.ci.lint_docs", list(args))
|
||||
|
||||
|
||||
@ci.command("notify-failure")
|
||||
@click.argument("args", nargs=-1)
|
||||
def ci_notify_failure(args: tuple[str, ...]) -> None:
|
||||
|
||||
+149
-45
@@ -144,9 +144,9 @@
|
||||
"zh": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments)."
|
||||
},
|
||||
"\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.": {
|
||||
"en": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
|
||||
"bg": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
|
||||
"de": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
|
||||
"en": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
|
||||
"pl": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
|
||||
"ru": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
|
||||
"zh": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'."
|
||||
@@ -216,9 +216,9 @@
|
||||
"zh": "\nWorkflow-only changes ({count}):"
|
||||
},
|
||||
"\n[check_test_coverage] Fix: add the missing test file(s) before committing.": {
|
||||
"en": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
|
||||
"bg": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
|
||||
"de": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
|
||||
"en": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
|
||||
"pl": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
|
||||
"ru": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
|
||||
"zh": "\n[check_test_coverage] Fix: add the missing test file(s) before committing."
|
||||
@@ -256,9 +256,9 @@
|
||||
"zh": "\n{tag} files ({count}):"
|
||||
},
|
||||
" Could not fetch logs: {error}": {
|
||||
"en": " Could not fetch logs: {error}",
|
||||
"bg": " Could not fetch logs: {error}",
|
||||
"de": " Could not fetch logs: {error}",
|
||||
"en": " Could not fetch logs: {error}",
|
||||
"pl": " Could not fetch logs: {error}",
|
||||
"ru": " Could not fetch logs: {error}",
|
||||
"zh": " Could not fetch logs: {error}"
|
||||
@@ -351,6 +351,14 @@
|
||||
"ru": " - Требуемые проверки статуса: {checks}",
|
||||
"zh": " - 必需状态检查: {checks}"
|
||||
},
|
||||
" Auto-fixed trailing whitespace in {n} files": {
|
||||
"bg": " Auto-fixed trailing whitespace in {n} files",
|
||||
"de": " Auto-fixed trailing whitespace in {n} files",
|
||||
"en": " Auto-fixed trailing whitespace in {n} files",
|
||||
"pl": " Auto-fixed trailing whitespace in {n} files",
|
||||
"ru": " Auto-fixed trailing whitespace in {n} files",
|
||||
"zh": " Auto-fixed trailing whitespace in {n} files"
|
||||
},
|
||||
" Collecting code quality...": {
|
||||
"bg": " Collecting code quality...",
|
||||
"de": " Collecting code quality...",
|
||||
@@ -423,13 +431,13 @@
|
||||
"ru": " Generated: {path}",
|
||||
"zh": " Generated: {path}"
|
||||
},
|
||||
" MISSING: devx {cmd}": {
|
||||
"bg": " ЛИПСВА: devx {cmd}",
|
||||
"de": " FEHLT: devx {cmd}",
|
||||
"en": " MISSING: devx {cmd}",
|
||||
"pl": " BRAK: devx {cmd}",
|
||||
"ru": " ОТСУТСТВУЕТ: devx {cmd}",
|
||||
"zh": " 缺失: devx {cmd}"
|
||||
" MISSING: {cmd}": {
|
||||
"bg": " MISSING: {cmd}",
|
||||
"de": " MISSING: {cmd}",
|
||||
"en": " MISSING: {cmd}",
|
||||
"pl": " MISSING: {cmd}",
|
||||
"ru": " MISSING: {cmd}",
|
||||
"zh": " MISSING: {cmd}"
|
||||
},
|
||||
" MISSING: {module}": {
|
||||
"bg": " MISSING: {module}",
|
||||
@@ -447,13 +455,13 @@
|
||||
"ru": " MISSING: {script}",
|
||||
"zh": " MISSING: {script}"
|
||||
},
|
||||
" OK: devx {cmd}": {
|
||||
"bg": " ОК: devx {cmd}",
|
||||
"de": " OK: devx {cmd}",
|
||||
"en": " OK: devx {cmd}",
|
||||
"pl": " OK: devx {cmd}",
|
||||
"ru": " ОК: devx {cmd}",
|
||||
"zh": " 正常: devx {cmd}"
|
||||
" OK: {cmd}": {
|
||||
"bg": " OK: {cmd}",
|
||||
"de": " OK: {cmd}",
|
||||
"en": " OK: {cmd}",
|
||||
"pl": " OK: {cmd}",
|
||||
"ru": " OK: {cmd}",
|
||||
"zh": " OK: {cmd}"
|
||||
},
|
||||
" OK: {module}": {
|
||||
"bg": " OK: {module}",
|
||||
@@ -607,6 +615,14 @@
|
||||
"ru": " {name}: {label}={message} ({color})",
|
||||
"zh": " {name}: {label}={message} ({color})"
|
||||
},
|
||||
" {n} stale docs found (warnings only)": {
|
||||
"bg": " {n} stale docs found (warnings only)",
|
||||
"de": " {n} stale docs found (warnings only)",
|
||||
"en": " {n} stale docs found (warnings only)",
|
||||
"pl": " {n} stale docs found (warnings only)",
|
||||
"ru": " {n} stale docs found (warnings only)",
|
||||
"zh": " {n} stale docs found (warnings only)"
|
||||
},
|
||||
" {version} (created: {created})": {
|
||||
"bg": " {version} (created: {created})",
|
||||
"de": " {version} (created: {created})",
|
||||
@@ -616,17 +632,17 @@
|
||||
"zh": " {version} (created: {created})"
|
||||
},
|
||||
"--checklist-categories must list at least 8 of 13 categories. Got {count}.": {
|
||||
"en": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
|
||||
"bg": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
|
||||
"de": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
|
||||
"en": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
|
||||
"pl": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
|
||||
"ru": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
|
||||
"zh": "--checklist-categories must list at least 8 of 13 categories. Got {count}."
|
||||
},
|
||||
"--checklist-confirmed is required for APPROVE events.": {
|
||||
"en": "--checklist-confirmed is required for APPROVE events.",
|
||||
"bg": "--checklist-confirmed is required for APPROVE events.",
|
||||
"de": "--checklist-confirmed is required for APPROVE events.",
|
||||
"en": "--checklist-confirmed is required for APPROVE events.",
|
||||
"pl": "--checklist-confirmed is required for APPROVE events.",
|
||||
"ru": "--checklist-confirmed is required for APPROVE events.",
|
||||
"zh": "--checklist-confirmed is required for APPROVE events."
|
||||
@@ -664,9 +680,9 @@
|
||||
"zh": "API poll warning: {exc}"
|
||||
},
|
||||
"Added label '{label}' to PR #{pr}.": {
|
||||
"en": "Added label '{label}' to PR #{pr}.",
|
||||
"bg": "Added label '{label}' to PR #{pr}.",
|
||||
"de": "Added label '{label}' to PR #{pr}.",
|
||||
"en": "Added label '{label}' to PR #{pr}.",
|
||||
"pl": "Added label '{label}' to PR #{pr}.",
|
||||
"ru": "Added label '{label}' to PR #{pr}.",
|
||||
"zh": "Added label '{label}' to PR #{pr}."
|
||||
@@ -808,17 +824,17 @@
|
||||
"zh": "Bumping version: {current} -> v{new_version}"
|
||||
},
|
||||
"CI checks did not complete within timeout.": {
|
||||
"en": "CI checks did not complete within timeout.",
|
||||
"bg": "CI checks did not complete within timeout.",
|
||||
"de": "CI checks did not complete within timeout.",
|
||||
"en": "CI checks did not complete within timeout.",
|
||||
"pl": "CI checks did not complete within timeout.",
|
||||
"ru": "CI checks did not complete within timeout.",
|
||||
"zh": "CI checks did not complete within timeout."
|
||||
},
|
||||
"CI checks failed.": {
|
||||
"en": "CI checks failed.",
|
||||
"bg": "CI checks failed.",
|
||||
"de": "CI checks failed.",
|
||||
"en": "CI checks failed.",
|
||||
"pl": "CI checks failed.",
|
||||
"ru": "CI checks failed.",
|
||||
"zh": "CI checks failed."
|
||||
@@ -832,9 +848,9 @@
|
||||
"zh": "CI_GITEA_TOKEN environment variable required"
|
||||
},
|
||||
"CI_GITEA_TOKEN is not set.": {
|
||||
"en": "CI_GITEA_TOKEN is not set.",
|
||||
"bg": "CI_GITEA_TOKEN is not set.",
|
||||
"de": "CI_GITEA_TOKEN is not set.",
|
||||
"en": "CI_GITEA_TOKEN is not set.",
|
||||
"pl": "CI_GITEA_TOKEN is not set.",
|
||||
"ru": "CI_GITEA_TOKEN is not set.",
|
||||
"zh": "CI_GITEA_TOKEN is not set."
|
||||
@@ -863,14 +879,78 @@
|
||||
"ru": "Checking CLI command documentation...",
|
||||
"zh": "Checking CLI command documentation..."
|
||||
},
|
||||
"Checking docs structure...": {
|
||||
"bg": "Checking docs structure...",
|
||||
"de": "Checking docs structure...",
|
||||
"en": "Checking docs structure...",
|
||||
"pl": "Checking docs structure...",
|
||||
"ru": "Checking docs structure...",
|
||||
"zh": "Checking docs structure..."
|
||||
},
|
||||
"Checking duplicate headings...": {
|
||||
"bg": "Checking duplicate headings...",
|
||||
"de": "Checking duplicate headings...",
|
||||
"en": "Checking duplicate headings...",
|
||||
"pl": "Checking duplicate headings...",
|
||||
"ru": "Checking duplicate headings...",
|
||||
"zh": "Checking duplicate headings..."
|
||||
},
|
||||
"Checking for TODO/FIXME markers...": {
|
||||
"bg": "Checking for TODO/FIXME markers...",
|
||||
"de": "Checking for TODO/FIXME markers...",
|
||||
"en": "Checking for TODO/FIXME markers...",
|
||||
"pl": "Checking for TODO/FIXME markers...",
|
||||
"ru": "Checking for TODO/FIXME markers...",
|
||||
"zh": "Checking for TODO/FIXME markers..."
|
||||
},
|
||||
"Checking for stale docs...": {
|
||||
"bg": "Checking for stale docs...",
|
||||
"de": "Checking for stale docs...",
|
||||
"en": "Checking for stale docs...",
|
||||
"pl": "Checking for stale docs...",
|
||||
"ru": "Checking for stale docs...",
|
||||
"zh": "Checking for stale docs..."
|
||||
},
|
||||
"Checking heading hierarchy...": {
|
||||
"bg": "Checking heading hierarchy...",
|
||||
"de": "Checking heading hierarchy...",
|
||||
"en": "Checking heading hierarchy...",
|
||||
"pl": "Checking heading hierarchy...",
|
||||
"ru": "Checking heading hierarchy...",
|
||||
"zh": "Checking heading hierarchy..."
|
||||
},
|
||||
"Checking internal links...": {
|
||||
"bg": "Checking internal links...",
|
||||
"de": "Checking internal links...",
|
||||
"en": "Checking internal links...",
|
||||
"pl": "Checking internal links...",
|
||||
"ru": "Checking internal links...",
|
||||
"zh": "Checking internal links..."
|
||||
},
|
||||
"Checking required files...": {
|
||||
"bg": "Checking required files...",
|
||||
"de": "Checking required files...",
|
||||
"en": "Checking required files...",
|
||||
"pl": "Checking required files...",
|
||||
"ru": "Checking required files...",
|
||||
"zh": "Checking required files..."
|
||||
},
|
||||
"Checking status for PR #{pr_number}...": {
|
||||
"en": "Checking status for PR #{pr_number}...",
|
||||
"bg": "Checking status for PR #{pr_number}...",
|
||||
"de": "Checking status for PR #{pr_number}...",
|
||||
"en": "Checking status for PR #{pr_number}...",
|
||||
"pl": "Checking status for PR #{pr_number}...",
|
||||
"ru": "Checking status for PR #{pr_number}...",
|
||||
"zh": "Checking status for PR #{pr_number}..."
|
||||
},
|
||||
"Checking trailing whitespace...": {
|
||||
"bg": "Checking trailing whitespace...",
|
||||
"de": "Checking trailing whitespace...",
|
||||
"en": "Checking trailing whitespace...",
|
||||
"pl": "Checking trailing whitespace...",
|
||||
"ru": "Checking trailing whitespace...",
|
||||
"zh": "Checking trailing whitespace..."
|
||||
},
|
||||
"Command failed ({cmd}): {stderr}": {
|
||||
"bg": "Command failed ({cmd}): {stderr}",
|
||||
"de": "Command failed ({cmd}): {stderr}",
|
||||
@@ -888,9 +968,9 @@
|
||||
"zh": "Commit message: {msg}"
|
||||
},
|
||||
"Commit: {sha}": {
|
||||
"en": "Commit: {sha}",
|
||||
"bg": "Commit: {sha}",
|
||||
"de": "Commit: {sha}",
|
||||
"en": "Commit: {sha}",
|
||||
"pl": "Commit: {sha}",
|
||||
"ru": "Commit: {sha}",
|
||||
"zh": "Commit: {sha}"
|
||||
@@ -912,9 +992,9 @@
|
||||
"zh": "配置正常: [tool.devx] 已存在, devx 版本一致。"
|
||||
},
|
||||
"Configuration validation failed.": {
|
||||
"en": "Configuration validation failed.",
|
||||
"bg": "Configuration validation failed.",
|
||||
"de": "Configuration validation failed.",
|
||||
"en": "Configuration validation failed.",
|
||||
"pl": "Configuration validation failed.",
|
||||
"ru": "Configuration validation failed.",
|
||||
"zh": "Configuration validation failed."
|
||||
@@ -952,9 +1032,9 @@
|
||||
"zh": "无法检测当前分支: {error}"
|
||||
},
|
||||
"Could not determine head SHA for PR #{pr_number}.": {
|
||||
"en": "Could not determine head SHA for PR #{pr_number}.",
|
||||
"bg": "Could not determine head SHA for PR #{pr_number}.",
|
||||
"de": "Could not determine head SHA for PR #{pr_number}.",
|
||||
"en": "Could not determine head SHA for PR #{pr_number}.",
|
||||
"pl": "Could not determine head SHA for PR #{pr_number}.",
|
||||
"ru": "Could not determine head SHA for PR #{pr_number}.",
|
||||
"zh": "Could not determine head SHA for PR #{pr_number}."
|
||||
@@ -1135,6 +1215,14 @@
|
||||
"ru": "Каждый элемент должен быть строкой или объектом с 'id', получено {type}",
|
||||
"zh": "每个元素必须是字符串或带有 'id' 的对象,得到 {type}"
|
||||
},
|
||||
"FAIL: {n} documentation issues found:": {
|
||||
"bg": "FAIL: {n} documentation issues found:",
|
||||
"de": "FAIL: {n} documentation issues found:",
|
||||
"en": "FAIL: {n} documentation issues found:",
|
||||
"pl": "FAIL: {n} documentation issues found:",
|
||||
"ru": "FAIL: {n} documentation issues found:",
|
||||
"zh": "FAIL: {n} documentation issues found:"
|
||||
},
|
||||
"FAILED: {count} undocumented dependency/ies": {
|
||||
"bg": "FAILED: {count} undocumented dependency/ies",
|
||||
"de": "FAILED: {count} undocumented dependency/ies",
|
||||
@@ -1184,9 +1272,9 @@
|
||||
"zh": "Failed to list versions for {name}: {error}"
|
||||
},
|
||||
"Fetching logs for PR #{pr_number}...": {
|
||||
"en": "Fetching logs for PR #{pr_number}...",
|
||||
"bg": "Fetching logs for PR #{pr_number}...",
|
||||
"de": "Fetching logs for PR #{pr_number}...",
|
||||
"en": "Fetching logs for PR #{pr_number}...",
|
||||
"pl": "Fetching logs for PR #{pr_number}...",
|
||||
"ru": "Fetching logs for PR #{pr_number}...",
|
||||
"zh": "Fetching logs for PR #{pr_number}..."
|
||||
@@ -1384,9 +1472,9 @@
|
||||
"zh": "Integration tests passed."
|
||||
},
|
||||
"Invalid checklist category: {cat}. Must be numbers.": {
|
||||
"en": "Invalid checklist category: {cat}. Must be numbers.",
|
||||
"bg": "Invalid checklist category: {cat}. Must be numbers.",
|
||||
"de": "Invalid checklist category: {cat}. Must be numbers.",
|
||||
"en": "Invalid checklist category: {cat}. Must be numbers.",
|
||||
"pl": "Invalid checklist category: {cat}. Must be numbers.",
|
||||
"ru": "Invalid checklist category: {cat}. Must be numbers.",
|
||||
"zh": "Invalid checklist category: {cat}. Must be numbers."
|
||||
@@ -1400,17 +1488,17 @@
|
||||
"zh": "输入必须是 JSON 数组,得到 {type}"
|
||||
},
|
||||
"Label '{label}' already on PR #{pr}.": {
|
||||
"en": "Label '{label}' already on PR #{pr}.",
|
||||
"bg": "Label '{label}' already on PR #{pr}.",
|
||||
"de": "Label '{label}' already on PR #{pr}.",
|
||||
"en": "Label '{label}' already on PR #{pr}.",
|
||||
"pl": "Label '{label}' already on PR #{pr}.",
|
||||
"ru": "Label '{label}' already on PR #{pr}.",
|
||||
"zh": "Label '{label}' already on PR #{pr}."
|
||||
},
|
||||
"Latest run: #{run_id} (status: {status})": {
|
||||
"en": "Latest run: #{run_id} (status: {status})",
|
||||
"bg": "Latest run: #{run_id} (status: {status})",
|
||||
"de": "Latest run: #{run_id} (status: {status})",
|
||||
"en": "Latest run: #{run_id} (status: {status})",
|
||||
"pl": "Latest run: #{run_id} (status: {status})",
|
||||
"ru": "Latest run: #{run_id} (status: {status})",
|
||||
"zh": "Latest run: #{run_id} (status: {status})"
|
||||
@@ -1431,6 +1519,14 @@
|
||||
"ru": "Lint passed.",
|
||||
"zh": "Lint passed."
|
||||
},
|
||||
"Linting documentation in {root}...": {
|
||||
"bg": "Linting documentation in {root}...",
|
||||
"de": "Linting documentation in {root}...",
|
||||
"en": "Linting documentation in {root}...",
|
||||
"pl": "Linting documentation in {root}...",
|
||||
"ru": "Linting documentation in {root}...",
|
||||
"zh": "Linting documentation in {root}..."
|
||||
},
|
||||
"Manifest file not found: {path}": {
|
||||
"bg": "Manifest file not found: {path}",
|
||||
"de": "Manifest file not found: {path}",
|
||||
@@ -1472,9 +1568,9 @@
|
||||
"zh": "合并失败: HTTP {status}: {message}\n请检查 PR 是否准备就绪且您具有合并权限。"
|
||||
},
|
||||
"Missing tests for changed files.": {
|
||||
"en": "Missing tests for changed files.",
|
||||
"bg": "Missing tests for changed files.",
|
||||
"de": "Missing tests for changed files.",
|
||||
"en": "Missing tests for changed files.",
|
||||
"pl": "Missing tests for changed files.",
|
||||
"ru": "Missing tests for changed files.",
|
||||
"zh": "Missing tests for changed files."
|
||||
@@ -1536,9 +1632,9 @@
|
||||
"zh": "不错!Vikunja 任务 {task_id} (ID {vikunja_id}) 已更新并标记为完成。"
|
||||
},
|
||||
"No CI checks found for commit {sha}.": {
|
||||
"en": "No CI checks found for commit {sha}.",
|
||||
"bg": "No CI checks found for commit {sha}.",
|
||||
"de": "No CI checks found for commit {sha}.",
|
||||
"en": "No CI checks found for commit {sha}.",
|
||||
"pl": "No CI checks found for commit {sha}.",
|
||||
"ru": "No CI checks found for commit {sha}.",
|
||||
"zh": "No CI checks found for commit {sha}."
|
||||
@@ -1568,33 +1664,33 @@
|
||||
"zh": "No changes between {base} and {head}."
|
||||
},
|
||||
"No failed jobs.": {
|
||||
"en": "No failed jobs.",
|
||||
"bg": "No failed jobs.",
|
||||
"de": "No failed jobs.",
|
||||
"en": "No failed jobs.",
|
||||
"pl": "No failed jobs.",
|
||||
"ru": "No failed jobs.",
|
||||
"zh": "No failed jobs."
|
||||
},
|
||||
"No job matching '{job}' found.": {
|
||||
"en": "No job matching '{job}' found.",
|
||||
"bg": "No job matching '{job}' found.",
|
||||
"de": "No job matching '{job}' found.",
|
||||
"en": "No job matching '{job}' found.",
|
||||
"pl": "No job matching '{job}' found.",
|
||||
"ru": "No job matching '{job}' found.",
|
||||
"zh": "No job matching '{job}' found."
|
||||
},
|
||||
"No jobs found for run #{run_id}.": {
|
||||
"en": "No jobs found for run #{run_id}.",
|
||||
"bg": "No jobs found for run #{run_id}.",
|
||||
"de": "No jobs found for run #{run_id}.",
|
||||
"en": "No jobs found for run #{run_id}.",
|
||||
"pl": "No jobs found for run #{run_id}.",
|
||||
"ru": "No jobs found for run #{run_id}.",
|
||||
"zh": "No jobs found for run #{run_id}."
|
||||
},
|
||||
"No open PR found for branch '{branch}'.": {
|
||||
"en": "No open PR found for branch '{branch}'.",
|
||||
"bg": "No open PR found for branch '{branch}'.",
|
||||
"de": "No open PR found for branch '{branch}'.",
|
||||
"en": "No open PR found for branch '{branch}'.",
|
||||
"pl": "No open PR found for branch '{branch}'.",
|
||||
"ru": "No open PR found for branch '{branch}'.",
|
||||
"zh": "No open PR found for branch '{branch}'."
|
||||
@@ -1664,9 +1760,9 @@
|
||||
"zh": "No versions found."
|
||||
},
|
||||
"No workflow runs found for SHA {sha}.": {
|
||||
"en": "No workflow runs found for SHA {sha}.",
|
||||
"bg": "No workflow runs found for SHA {sha}.",
|
||||
"de": "No workflow runs found for SHA {sha}.",
|
||||
"en": "No workflow runs found for SHA {sha}.",
|
||||
"pl": "No workflow runs found for SHA {sha}.",
|
||||
"ru": "No workflow runs found for SHA {sha}.",
|
||||
"zh": "No workflow runs found for SHA {sha}."
|
||||
@@ -1767,6 +1863,14 @@
|
||||
"ru": "Ой! Публикация в PyPI не удалась:\n{stderr}",
|
||||
"zh": "哎呀!PyPI 发布失败:\n{stderr}"
|
||||
},
|
||||
"PASS: All documentation checks passed!": {
|
||||
"bg": "PASS: All documentation checks passed!",
|
||||
"de": "PASS: All documentation checks passed!",
|
||||
"en": "PASS: All documentation checks passed!",
|
||||
"pl": "PASS: All documentation checks passed!",
|
||||
"ru": "PASS: All documentation checks passed!",
|
||||
"zh": "PASS: All documentation checks passed!"
|
||||
},
|
||||
"PASSED: {pair}": {
|
||||
"bg": "PASSED: {pair}",
|
||||
"de": "PASSED: {pair}",
|
||||
@@ -1848,9 +1952,9 @@
|
||||
"zh": "未设置 PYPI_TOKEN 且未配置 registry URL — 跳过 PyPI 发布。别担心,我们直接创建 Gitea release。"
|
||||
},
|
||||
"Package owner not specified. Use --owner or set [tool.devx] repo_owner.": {
|
||||
"en": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
|
||||
"bg": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
|
||||
"de": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
|
||||
"en": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
|
||||
"pl": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
|
||||
"ru": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
|
||||
"zh": "Package owner not specified. Use --owner or set [tool.devx] repo_owner."
|
||||
@@ -2072,9 +2176,9 @@
|
||||
"zh": "Repository in owner/name format"
|
||||
},
|
||||
"Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.": {
|
||||
"en": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
|
||||
"bg": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
|
||||
"de": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
|
||||
"en": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
|
||||
"pl": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
|
||||
"ru": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
|
||||
"zh": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var."
|
||||
@@ -2088,9 +2192,9 @@
|
||||
"zh": "仓库所有者未设置。使用 --owner 或 DEVX_REPO_OWNER 环境变量。"
|
||||
},
|
||||
"Review body must be at least 50 characters.": {
|
||||
"en": "Review body must be at least 50 characters.",
|
||||
"bg": "Review body must be at least 50 characters.",
|
||||
"de": "Review body must be at least 50 characters.",
|
||||
"en": "Review body must be at least 50 characters.",
|
||||
"pl": "Review body must be at least 50 characters.",
|
||||
"ru": "Review body must be at least 50 characters.",
|
||||
"zh": "Review body must be at least 50 characters."
|
||||
@@ -2296,9 +2400,9 @@
|
||||
"zh": "Tests passed."
|
||||
},
|
||||
"Timeout reached after {timeout}s.": {
|
||||
"en": "Timeout reached after {timeout}s.",
|
||||
"bg": "Timeout reached after {timeout}s.",
|
||||
"de": "Timeout reached after {timeout}s.",
|
||||
"en": "Timeout reached after {timeout}s.",
|
||||
"pl": "Timeout reached after {timeout}s.",
|
||||
"ru": "Timeout reached after {timeout}s.",
|
||||
"zh": "Timeout reached after {timeout}s."
|
||||
@@ -2432,9 +2536,9 @@
|
||||
"zh": "警告: VIKUNJA_TOKEN 未设置 — 跳过任务存在性检查。在 .env 中设置以启用完整验证。"
|
||||
},
|
||||
"Waiting for CI checks to complete (timeout: {timeout}s)...": {
|
||||
"en": "Waiting for CI checks to complete (timeout: {timeout}s)...",
|
||||
"bg": "Waiting for CI checks to complete (timeout: {timeout}s)...",
|
||||
"de": "Waiting for CI checks to complete (timeout: {timeout}s)...",
|
||||
"en": "Waiting for CI checks to complete (timeout: {timeout}s)...",
|
||||
"pl": "Waiting for CI checks to complete (timeout: {timeout}s)...",
|
||||
"ru": "Waiting for CI checks to complete (timeout: {timeout}s)...",
|
||||
"zh": "Waiting for CI checks to complete (timeout: {timeout}s)..."
|
||||
@@ -2515,9 +2619,9 @@
|
||||
"bg": "Wrote tag {tag} to GITHUB_OUTPUT.",
|
||||
"de": "Wrote tag {tag} to GITHUB_OUTPUT.",
|
||||
"en": "Wrote tag {tag} to GITHUB_OUTPUT.",
|
||||
"pl": "Wrote tag {tag} to GITHUB_OUTPUT.",
|
||||
"ru": "Wrote tag {tag} to GITHUB_OUTPUT.",
|
||||
"zh": "Wrote tag {tag} to GITHUB_OUTPUT.",
|
||||
"pl": "Wrote tag {tag} to GITHUB_OUTPUT."
|
||||
"zh": "Wrote tag {tag} to GITHUB_OUTPUT."
|
||||
},
|
||||
"[check-dep-docs] Passed: all dependencies are documented": {
|
||||
"bg": "[check-dep-docs] Passed: all dependencies are documented",
|
||||
|
||||
Reference in New Issue
Block a user