DEVX-118: feat: enrich lint_docs.py with single H1, max depth, line length, code block lang, orphan checks
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / vikunja (push) Successful in 19s
Post-merge / configure-repo (push) Successful in 16s
Post-merge / release (push) Successful in 40s
Post-merge / sync-wiki (push) Successful in 43s
Post-merge / publish (push) Successful in 28s
Post-merge / badges (push) Failing after 36s

- Add check_single_h1: each markdown file should have at most one H1
- Add check_max_heading_depth: headings should not exceed H4 (configurable)
- Add check_line_length: warn on lines >120 chars (non-blocking — badge URLs)
- Add check_code_block_languages: fenced code blocks must specify a language
- Add check_orphan_docs: warn on docs not linked from index.md or mapping.json
- Fix all code blocks in docs to specify language (text for plain blocks)
- Fix duplicate H1 in .vale/styles/devx/README.md
- Add 18 new tests for full coverage of new checks

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
emil
2026-07-06 10:15:54 +02:00
co-authored by Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent bb700ab969
commit fb342e7b9d
9 changed files with 342 additions and 15 deletions
+173 -2
View File
@@ -7,6 +7,12 @@ Checks performed (all configurable via pyproject.toml ``[tool.devx.docs]``):
- **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., ``#`` → ``###``).
- **Single H1**: each markdown file should have at most one H1 heading.
- **Max heading depth**: headings should not exceed H4 (configurable).
- **Max line length**: lines should not exceed 120 characters (configurable).
- **Code block language**: fenced code blocks should specify a language.
- **Orphan docs**: docs not linked from index.md or mapping.json (warning).
- **Mapping completeness**: all docs/*.md should be in mapping.json (warning).
- **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.
@@ -49,6 +55,15 @@ REQUIRED_DOC_FILES = ["index.md"]
# Maximum age for docs before they're considered stale (days)
STALE_THRESHOLD_DAYS = 180
# Maximum heading depth (H4 by default)
MAX_HEADING_DEPTH = 4
# Maximum line length
MAX_LINE_LENGTH = 120
# Code block without language: ``` followed by optional whitespace only
_CODE_BLOCK_NO_LANG_RE = re.compile(r"^```[ \t]*$", re.MULTILINE)
# Files excluded from duplicate heading checks (auto-generated or structured
# with repeated subsections under different parent sections)
DUPLICATE_HEADING_EXCLUDES = {
@@ -318,6 +333,120 @@ def check_duplicate_headings(root: Path) -> list[str]:
return issues
def check_single_h1(root: Path) -> list[str]:
"""Check that each markdown file has at most one H1 heading."""
issues: list[str] = []
md_files = [f for f in root.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
for md_file in md_files:
rel_path = md_file.relative_to(root)
if md_file.name in DUPLICATE_HEADING_EXCLUDES:
continue
content = strip_code_blocks(md_file.read_text(encoding="utf-8"))
h1_count = len(re.findall(r"^#\s+", content, re.MULTILINE))
if h1_count > 1:
issues.append(f"{rel_path}: {h1_count} H1 headings — should have at most 1")
return issues
def check_max_heading_depth(root: Path) -> list[str]:
"""Check that headings don't exceed MAX_HEADING_DEPTH."""
issues: list[str] = []
md_files = [f for f in root.rglob("*.md") if not any(part in _EXCLUDE_DIRS 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"))
for match in re.finditer(r"^(#{1,6})\s+", content, re.MULTILINE):
level = len(match.group(1))
if level > MAX_HEADING_DEPTH:
line_num = content[: match.start()].count("\n") + 1
issues.append(f"{rel_path}:{line_num}: heading depth H{level} exceeds max H{MAX_HEADING_DEPTH}")
return issues
def check_line_length(root: Path) -> list[str]:
"""Check that no lines exceed MAX_LINE_LENGTH characters."""
issues: list[str] = []
md_files = [f for f in root.rglob("*.md") if not any(part in _EXCLUDE_DIRS 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 len(line) > MAX_LINE_LENGTH:
issues.append(f"{rel_path}:{i}: line too long ({len(line)} > {MAX_LINE_LENGTH} chars)")
return issues
def check_code_block_languages(root: Path) -> list[str]:
"""Check that fenced code blocks specify a language."""
issues: list[str] = []
md_files = [f for f in root.rglob("*.md") if not any(part in _EXCLUDE_DIRS 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")
in_code_block = False
for i, line in enumerate(content.splitlines(), 1):
stripped = line.strip()
if stripped.startswith("```"):
if not in_code_block:
# Opening fence — check for language
if _CODE_BLOCK_NO_LANG_RE.match(line):
issues.append(f"{rel_path}:{i}: code block without language specifier")
in_code_block = True
else:
# Closing fence
in_code_block = False
return issues
def check_orphan_docs(root: Path, docs_dir: Path) -> list[str]:
"""Check for docs not linked from index.md or mapping.json (warnings)."""
issues: list[str] = []
if not docs_dir.is_dir():
return issues
# Collect all referenced files from index.md and mapping.json
referenced: set[str] = set()
index_file = docs_dir / "index.md"
if index_file.exists():
content = index_file.read_text(encoding="utf-8")
for match in _LINK_RE.finditer(content):
url = match.group(2).strip()
if not url.startswith(("http://", "https://", "mailto:")):
referenced.add(url.split("#")[0])
mapping_file = docs_dir / "mapping.json"
if mapping_file.exists():
try:
mapping = json.loads(mapping_file.read_text(encoding="utf-8"))
if isinstance(mapping, dict):
# Add both keys (filenames) and values (wiki page names)
for k, v in mapping.items():
if isinstance(k, str):
referenced.add(k)
if isinstance(v, str):
referenced.add(v)
except (json.JSONDecodeError, AttributeError):
pass
# Check each doc file
for md_file in sorted(docs_dir.rglob("*.md")):
if md_file.name == "index.md":
continue
rel_path = md_file.relative_to(docs_dir).as_posix()
if rel_path not in referenced and md_file.name not in referenced:
issues.append(f"docs/{rel_path}: orphan doc — not linked from index.md or mapping.json")
return issues
@click.command()
@click.option("--root", default=".", help="Repository root directory.")
@click.option("--docs-dir", default=None, help="Docs directory (default: <root>/docs).")
@@ -327,6 +456,11 @@ def check_duplicate_headings(root: Path) -> list[str]:
@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("--check-single-h1/--no-check-single-h1", "single_h1", default=True, help="Check single H1 per file.")
@click.option("--check-depth/--no-check-depth", "depth", default=True, help="Check max heading depth.")
@click.option("--check-line-length/--no-check-line-length", "line_length", default=True, help="Check line length.")
@click.option("--check-code-lang/--no-check-code-lang", "code_lang", default=True, help="Check code block languages.")
@click.option("--check-orphans/--no-check-orphans", "orphans", default=False, help="Check for orphan docs (warnings).")
@click.option("--fix", is_flag=True, default=False, help="Auto-fix trailing whitespace.")
def main(
root: str,
@@ -337,6 +471,11 @@ def main(
check_stale: bool,
check_trailing: bool,
check_duplicates: bool,
single_h1: bool,
depth: bool,
line_length: bool,
code_lang: bool,
orphans: bool,
fix: bool,
) -> None:
"""Lint documentation files for structure, links, and quality."""
@@ -369,6 +508,31 @@ def main(
click.echo(_("Checking duplicate headings..."))
all_issues.extend(check_duplicate_headings(root_path))
# Single H1
if single_h1:
click.echo(_("Checking single H1 per file..."))
all_issues.extend(check_single_h1(root_path))
# Max heading depth
if depth:
click.echo(_("Checking max heading depth..."))
all_issues.extend(check_max_heading_depth(root_path))
# Line length (warnings — badge URLs and tables can exceed 120)
if line_length:
click.echo(_("Checking line length..."))
ll_issues = check_line_length(root_path)
for issue in ll_issues[:10]: # Show first 10 only
click.echo(f" WARN: {issue}")
if len(ll_issues) > 10:
click.echo(_(" ... and {n} more", n=len(ll_issues) - 10))
click.echo(_(" {n} long lines found (warnings only)", n=len(ll_issues)))
# Code block languages
if code_lang:
click.echo(_("Checking code block languages..."))
all_issues.extend(check_code_block_languages(root_path))
# TODO/FIXME
if check_todo:
click.echo(_("Checking for TODO/FIXME markers..."))
@@ -391,15 +555,22 @@ def main(
else:
all_issues.extend(ws_issues)
# Stale docs
# Stale docs (warnings)
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)))
# Orphan docs (warnings)
if orphans:
click.echo(_("Checking for orphan docs..."))
orphan_issues = check_orphan_docs(root_path, docs_path)
for issue in orphan_issues:
click.echo(f" WARN: {issue}")
click.echo(_(" {n} orphan docs found (warnings only)", n=len(orphan_issues)))
# Report
click.echo(f"\n{'=' * 60}")
if all_issues: