From b4b8df434f3d824c45d7a1ecc18658b76abc62eb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 17:36:27 +0000 Subject: [PATCH] Add CI to validate docs links and table structure This repo had no CI configured. Adds a lightweight GitHub Actions workflow that runs a dependency-free Python script on every push/PR to main, checking README.md and docs/*.md for broken relative links, unbalanced brackets/parens, and malformed markdown tables. --- .github/workflows/docs-check.yml | 18 ++++++ scripts/check_docs.py | 100 +++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 .github/workflows/docs-check.yml create mode 100644 scripts/check_docs.py diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml new file mode 100644 index 0000000..84afff0 --- /dev/null +++ b/.github/workflows/docs-check.yml @@ -0,0 +1,18 @@ +name: Docs check + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + - name: Validate markdown docs (links, tables) + run: python3 scripts/check_docs.py diff --git a/scripts/check_docs.py b/scripts/check_docs.py new file mode 100644 index 0000000..fe6305f --- /dev/null +++ b/scripts/check_docs.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Validate the project's markdown docs: link integrity and table shape. + +Checks every README.md / docs/*.md file for: +- balanced [] and () (catches broken/truncated markdown links) +- relative links that resolve to an existing file +- markdown tables where every row has the same column count as the header + +Exits non-zero if any file has issues, printing a report to stdout. +""" +import os +import re +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def find_docs(): + docs = [os.path.join(ROOT, "README.md")] + docs_dir = os.path.join(ROOT, "docs") + if os.path.isdir(docs_dir): + for name in sorted(os.listdir(docs_dir)): + if name.endswith(".md"): + docs.append(os.path.join(docs_dir, name)) + return [d for d in docs if os.path.isfile(d)] + + +def check_brackets(text): + issues = [] + if text.count("[") != text.count("]"): + issues.append(f"bracket mismatch: {text.count('[')} [ vs {text.count(']')} ]") + if text.count("(") != text.count(")"): + issues.append(f"paren mismatch: {text.count('(')} ( vs {text.count(')')} )") + return issues + + +def check_links(path, text): + issues = [] + base = os.path.dirname(path) + for m in re.finditer(r"\[[^\]]*\]\(([^)]+)\)", text): + target = m.group(1).strip() + if target.startswith("http://") or target.startswith("https://") or target.startswith("#") or target.startswith("mailto:"): + continue + target_path = target.split("#", 1)[0] + resolved = os.path.normpath(os.path.join(base, target_path)) + if not os.path.exists(resolved): + issues.append(f"broken link: {target} -> {os.path.relpath(resolved, ROOT)}") + return issues + + +def check_tables(text): + issues = [] + lines = text.split("\n") + i = 0 + while i < len(lines): + line = lines[i] + if line.strip().startswith("|"): + header_cols = line.count("|") + block_start = i + j = i + 1 + row_num = 1 + while j < len(lines) and lines[j].strip().startswith("|"): + cols = lines[j].count("|") + if cols != header_cols: + issues.append( + f"table column mismatch at line {j + 1} (row {row_num} of block starting line {block_start + 1}): " + f"expected {header_cols} pipes, got {cols}" + ) + row_num += 1 + j += 1 + i = j + else: + i += 1 + return issues + + +def main(): + docs = find_docs() + if not docs: + print("No markdown docs found.") + return 0 + + had_issues = False + for path in docs: + rel = os.path.relpath(path, ROOT) + text = open(path, encoding="utf-8").read() + issues = check_brackets(text) + check_links(path, text) + check_tables(text) + if issues: + had_issues = True + print(f"FAIL {rel}") + for issue in issues: + print(f" - {issue}") + else: + print(f"OK {rel}") + + return 1 if had_issues else 0 + + +if __name__ == "__main__": + sys.exit(main())