diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index f434de0..c1d4aa1 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -13,20 +13,5 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Validate versioned information architecture - shell: bash - run: | - set -euo pipefail - test -s README.md - test -s docs/README.md - test -s docs/v1/README.md - test -s docs/v1/quickstart.md - grep -Fq '1.0.0-draft' docs/v1/README.md - grep -Fq 'corelink_device_id' docs/v1/README.md - grep -Fq 'Authorization: Bearer $CORELINK_ACCESS_TOKEN' docs/v1/quickstart.md - grep -Fq 'Idempotency-Key: $CORELINK_IDEMPOTENCY_KEY' docs/v1/quickstart.md - grep -Fq '/api/v1/tenants/$CORELINK_TENANT_ID/devices' docs/v1/quickstart.md - if grep -Fq 'API contract repository also has unpopulated' README.md; then - echo "stale API-contract status returned" >&2 - exit 1 - fi + - name: Validate versioned documentation integrity + run: python3 tools/check_docs.py diff --git a/tools/check_docs.py b/tools/check_docs.py new file mode 100644 index 0000000..e69668e --- /dev/null +++ b/tools/check_docs.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +import re +import sys +from pathlib import Path +from urllib.parse import unquote + +ROOT = Path(__file__).resolve().parents[1] +DOC_ROOT = ROOT / "docs" + +REQUIRED_FILES = [ + ROOT / "README.md", + DOC_ROOT / "README.md", + DOC_ROOT / "v1" / "README.md", + DOC_ROOT / "v1" / "quickstart.md", + DOC_ROOT / "v1" / "concepts" / "architecture.md", + DOC_ROOT / "v1" / "concepts" / "tenancy-authentication.md", + DOC_ROOT / "v1" / "guides" / "devices-and-commands.md", + DOC_ROOT / "v1" / "guides" / "telemetry-location-events.md", + DOC_ROOT / "v1" / "guides" / "webhooks-and-partner-operations.md", + DOC_ROOT / "v1" / "sdks" / "typescript.md", + DOC_ROOT / "v1" / "sdks" / "python.md", + DOC_ROOT / "v1" / "tools" / "developer-tools.md", + DOC_ROOT / "v1" / "operations" / "errors-retries-idempotency.md", + DOC_ROOT / "v1" / "operations" / "troubleshooting.md", + DOC_ROOT / "v1" / "reference" / "compatibility.md", + DOC_ROOT / "v1" / "reference" / "maturity.md", +] + +MATURITY_TERMS = {"Scaffold", "Experimental", "Alpha", "Beta", "Stable", "Deprecated", "Planned"} +LINK_RE = re.compile(r"(? list[Path]: + return sorted(p for p in ROOT.rglob("*.md") if ".git" not in p.parts) + + +def check_required(errors: list[str]) -> None: + for path in REQUIRED_FILES: + if not path.is_file() or path.stat().st_size == 0: + errors.append(f"missing or empty required documentation file: {path.relative_to(ROOT)}") + + +def check_local_links(errors: list[str]) -> None: + for path in markdown_files(): + text = path.read_text(encoding="utf-8") + for raw_target in LINK_RE.findall(text): + target = raw_target.strip().split(" ", 1)[0].strip("<>") + if not target or target.startswith(("http://", "https://", "mailto:", "#")): + continue + target = unquote(target.split("#", 1)[0]) + if not target: + continue + resolved = (path.parent / target).resolve() + try: + resolved.relative_to(ROOT.resolve()) + except ValueError: + errors.append(f"link escapes repository: {path.relative_to(ROOT)} -> {raw_target}") + continue + if not resolved.exists(): + errors.append(f"broken local link: {path.relative_to(ROOT)} -> {raw_target}") + + +def check_version_and_identity(errors: list[str]) -> None: + v1_index = (DOC_ROOT / "v1" / "README.md").read_text(encoding="utf-8") + quickstart = (DOC_ROOT / "v1" / "quickstart.md").read_text(encoding="utf-8") + required_markers = { + "docs/v1/README.md": ["1.0.0-draft", "corelink_device_id", "CoreLink Console"], + "docs/v1/quickstart.md": [ + "Authorization: Bearer $CORELINK_ACCESS_TOKEN", + "Idempotency-Key: $CORELINK_IDEMPOTENCY_KEY", + "/api/v1/tenants/$CORELINK_TENANT_ID/devices", + ], + } + texts = {"docs/v1/README.md": v1_index, "docs/v1/quickstart.md": quickstart} + for filename, markers in required_markers.items(): + for marker in markers: + if marker not in texts[filename]: + errors.append(f"missing required version/contract marker in {filename}: {marker}") + + +def check_maturity_vocabulary(errors: list[str]) -> None: + maturity_page = (DOC_ROOT / "v1" / "reference" / "maturity.md").read_text(encoding="utf-8") + missing = sorted(term for term in MATURITY_TERMS if term not in maturity_page) + if missing: + errors.append(f"maturity reference is missing terms: {', '.join(missing)}") + + +def check_stale_claims(errors: list[str]) -> None: + forbidden = { + "future console-web": "Console is the canonical existing frontend repository", + "API contract repository also has unpopulated": "the contract repository is populated", + "OpenAPI and AsyncAPI source files in `api-contracts` are also currently empty": "contract sources are populated", + } + for path in markdown_files(): + text = path.read_text(encoding="utf-8") + for phrase, reason in forbidden.items(): + if phrase in text: + errors.append(f"stale claim in {path.relative_to(ROOT)}: {phrase!r} ({reason})") + + +def check_headings(errors: list[str]) -> None: + for path in markdown_files(): + text = path.read_text(encoding="utf-8") + headings = HEADING_RE.findall(text) + if not headings: + errors.append(f"markdown file has no heading: {path.relative_to(ROOT)}") + + +def main() -> int: + errors: list[str] = [] + check_required(errors) + if not errors: + check_version_and_identity(errors) + check_maturity_vocabulary(errors) + check_local_links(errors) + check_stale_claims(errors) + check_headings(errors) + + if errors: + print("Documentation integrity check failed:", file=sys.stderr) + for error in errors: + print(f"- {error}", file=sys.stderr) + return 1 + + print(f"Documentation integrity check passed for {len(markdown_files())} Markdown files.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())