From c7c2a0ee39ab65da324682ae73b2b48e6c5b8d68 Mon Sep 17 00:00:00 2001 From: Samran Asif Date: Wed, 9 Sep 2026 17:04:06 +0500 Subject: [PATCH] docs: give this project a documentation site, built the way that actually works This was the one repository in the family with no docs site at all: no mkdocs.yml, no Pages workflow, nine loose files browsable only on GitHub. The setup is ported from devrepro-doctor, but not as it stood. Porting it is what revealed that devrepro-doctor's own site had been publishing empty pages for months: every `--8<--` include there used a `../` path, which pymdownx.snippets refuses because it escapes base_path, and `check_paths: false` made the refusal silent. I wrote the same broken pattern here first, hit the same failure, and went back to check whether it had ever worked there. It had not. So this site uses base_path at the repository root with includes that stay inside it, and `check_paths: true` so a missing snippet fails the build rather than quietly producing a page of navigation chrome. Root documents stay the single source of truth; the site includes them rather than copying them. scripts/check_docs_site.py asserts the built site has content. Its threshold is split by page kind, which the first version got wrong: a flat 120-word bar reported docs/sdk.md (18 lines, mostly a code block) and docs/spec-support.md as broken when both are simply terse. The defect being guarded is an include resolving to nothing, so include-backed pages get the strict bar and everything else only has to be non-blank. Worth recording: my own scripts/check_action_pins.py caught two invented SHAs in this workflow. I had typed the Pages action pins from memory instead of copying them, and neither resolved to any tag. That is exactly the check it was written for, catching exactly the mistake it was written for, on its author. 20 pages build, all carrying real content, and the README links the site. --- .github/workflows/docs.yml | 64 +++++++++++++++++++++ README.md | 2 + docs/architecture.md | 1 + docs/changelog.md | 1 + docs/code-of-conduct.md | 1 + docs/contributing.md | 1 + docs/index.md | 42 ++++++++++++++ docs/product-gaps.md | 1 + docs/protocol-support.md | 1 + docs/roadmap.md | 1 + docs/safety-model.md | 1 + docs/security.md | 1 + mkdocs.yml | 77 +++++++++++++++++++++++++ pyproject.toml | 1 + requirements-docs.txt | 3 + scripts/check_docs_site.py | 111 +++++++++++++++++++++++++++++++++++++ 16 files changed, 309 insertions(+) create mode 100644 .github/workflows/docs.yml create mode 100644 docs/architecture.md create mode 100644 docs/changelog.md create mode 100644 docs/code-of-conduct.md create mode 100644 docs/contributing.md create mode 100644 docs/index.md create mode 100644 docs/product-gaps.md create mode 100644 docs/protocol-support.md create mode 100644 docs/roadmap.md create mode 100644 docs/safety-model.md create mode 100644 docs/security.md create mode 100644 mkdocs.yml create mode 100644 requirements-docs.txt create mode 100644 scripts/check_docs_site.py diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..3727bf7 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,64 @@ +name: Docs + +on: + push: + branches: [main] + paths: + - 'docs/**' + - '*.md' + - 'mkdocs.yml' + - 'requirements-docs.txt' + - '.github/workflows/docs.yml' + pull_request: + branches: [main] + paths: + - 'docs/**' + - '*.md' + - 'mkdocs.yml' + - 'requirements-docs.txt' + - '.github/workflows/docs.yml' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages-${{ github.ref }} + cancel-in-progress: false + +jobs: + build: + name: Docs site + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + - name: Install docs toolchain + run: pip install -r requirements-docs.txt + - name: Build docs (strict) + run: mkdocs build --strict --site-dir site + - name: Docs site actually contains its documents + # A green mkdocs build does not mean the pages have anything in them. + # devrepro-doctor published empty pages for months because every + # snippet include silently resolved to nothing. + run: python scripts/check_docs_site.py --site site + - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + if: github.ref == 'refs/heads/main' + with: + path: site + + deploy: + name: Deploy docs + if: github.ref == 'refs/heads/main' + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 diff --git a/README.md b/README.md index 90cb01e..d15f6f5 100644 --- a/README.md +++ b/README.md @@ -292,6 +292,8 @@ See [CONTRIBUTING.md](CONTRIBUTING.md), [SECURITY.md](SECURITY.md), ## Documentation +Browsable at ****, or as files here: + | Document | What it covers | |---|---| | [ARCHITECTURE.md](ARCHITECTURE.md) | The normalized contract model every engine reads, and how change ids are built | diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..a80de5b --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1 @@ +--8<-- "ARCHITECTURE.md" diff --git a/docs/changelog.md b/docs/changelog.md new file mode 100644 index 0000000..786b75d --- /dev/null +++ b/docs/changelog.md @@ -0,0 +1 @@ +--8<-- "CHANGELOG.md" diff --git a/docs/code-of-conduct.md b/docs/code-of-conduct.md new file mode 100644 index 0000000..01f2ea2 --- /dev/null +++ b/docs/code-of-conduct.md @@ -0,0 +1 @@ +--8<-- "CODE_OF_CONDUCT.md" diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 0000000..ea38c9b --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1 @@ +--8<-- "CONTRIBUTING.md" diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..83ab2c5 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,42 @@ +# api-verity-lab + +**Unified API contract governance, breaking-change analysis, schema-driven +testing, runtime drift detection, traffic replay and performance regression +for OpenAPI, AsyncAPI, GraphQL and gRPC.** + +Every supported spec format compiles into one normalized contract model, and +every engine downstream reads that model rather than the original document. +That is what lets a breaking-change rule, a fuzz generator and a drift check +agree about what an operation is. + +## Start here + +| If you want to | Read | +|---|---| +| Understand the design | [Architecture](architecture.md) | +| Know which rule fired, and why | [Rule catalog](rule-catalog.md) | +| Know what each format supports | [Spec support](spec-support.md) · [Protocol support](protocol-support.md) | +| Gate a pipeline on contract changes | [CI contract gate](ci.md) | +| Use it as a library | [SDK](sdk.md) | +| Branch on results in a script | [Exit codes](exit-codes.md) | +| Judge whether it is ready for you | [Capability status](capability-status.md) · [Product gaps](product-gaps.md) | + +## What this project promises about its own output + +It is a verification tool, so one class of bug matters more than the rest: the +tool asserting something it did not establish. Three habits follow, and each is +enforced in CI rather than intended. + +- **Never assert what the run did not establish.** A field that cannot be + derived reads `unknown`, with a reason. Provenance in an artifact that gates + someone else's build has to be earned. +- **Documented output is captured, never written.** The examples in the README + come from real runs; the rule catalogue is generated from the code; the + competitive table is rendered from committed API data. CI fails when a + generated file and its source disagree. +- **Counts are derived.** Rule totals, command counts and page counts come + from the source, not from prose that was true once. + +The [rule catalog](rule-catalog.md) is generated. The +[competitive analysis](competitive-analysis.md) is rendered from data fetched +by a checked-in script. If either disagrees with its source, the build fails. diff --git a/docs/product-gaps.md b/docs/product-gaps.md new file mode 100644 index 0000000..9a9e8a5 --- /dev/null +++ b/docs/product-gaps.md @@ -0,0 +1 @@ +--8<-- "PRODUCT_GAPS.md" diff --git a/docs/protocol-support.md b/docs/protocol-support.md new file mode 100644 index 0000000..f2b483f --- /dev/null +++ b/docs/protocol-support.md @@ -0,0 +1 @@ +--8<-- "PROTOCOL_SUPPORT.md" diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..40fbfee --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1 @@ +--8<-- "ROADMAP.md" diff --git a/docs/safety-model.md b/docs/safety-model.md new file mode 100644 index 0000000..6fb3c36 --- /dev/null +++ b/docs/safety-model.md @@ -0,0 +1 @@ +--8<-- "SAFETY_MODEL.md" diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..a8ada70 --- /dev/null +++ b/docs/security.md @@ -0,0 +1 @@ +--8<-- "SECURITY.md" diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..28427b0 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,77 @@ +site_name: api-verity-lab +site_description: > + Unified API contract governance, breaking-change analysis, schema-driven + testing, runtime drift detection, traffic replay and performance regression + for OpenAPI, AsyncAPI, GraphQL and gRPC. +site_url: https://webdevsamran.github.io/api-verity-lab/ +repo_url: https://github.com/webdevsamran/api-verity-lab +repo_name: webdevsamran/api-verity-lab + +theme: + name: material + palette: + - media: "(prefers-color-scheme: dark)" + scheme: slate + toggle: + icon: material/weather-night + name: Switch to light mode + - media: "(prefers-color-scheme: light)" + scheme: default + toggle: + icon: material/weather-sunny + name: Switch to dark mode + features: + - navigation.sections + - navigation.expand + - content.code.copy + - search.highlight + icon: + logo: material/check-decagram + +nav: + - Home: index.md + - Architecture: architecture.md + - Roadmap: roadmap.md + - Reference: + - Rule catalog: rule-catalog.md + - Spec support: spec-support.md + - Protocol support: protocol-support.md + - Exit codes: exit-codes.md + - Guides: + - CI contract gate: ci.md + - SDK: sdk.md + - Self-hosting: self-hosting.md + - Workflow authoring: workflow-authoring.md + - Status: + - Capability status: capability-status.md + - Product gaps: product-gaps.md + - Competitive analysis: competitive-analysis.md + - Project: + - Contributing: contributing.md + - Security policy: security.md + - Safety model: safety-model.md + - Privacy: privacy.md + - Changelog: changelog.md + - Code of conduct: code-of-conduct.md + +markdown_extensions: + - admonition + - pymdownx.superfences + - pymdownx.snippets: + # Repo root, so an include can reach ARCHITECTURE.md etc. A `../` + # path escapes base_path and pymdownx refuses it -- which is exactly + # how devrepro-doctor published empty pages for months. + base_path: . + check_paths: true + - tables + - toc: + permalink: true + +validation: + omitted_files: warn + absolute_links: warn + links: + # Root documents included by snippet carry links relative to the repository + # root, which is correct where they are mostly read. check_paths above is + # the check that matters, and it fails the build. + not_found: info diff --git a/pyproject.toml b/pyproject.toml index 5f5107a..8a55e7d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -141,6 +141,7 @@ exclude_lines = [ ] [tool.ruff.lint.per-file-ignores] +"scripts/check_docs_site.py" = ["S603", "RUF100"] # Same shared-script situation: the gh endpoint is built from the hardcoded # REPOS list above it, not from user input. "scripts/fetch_competitor_meta.py" = ["S603", "S607", "RUF100"] diff --git a/requirements-docs.txt b/requirements-docs.txt new file mode 100644 index 0000000..7748a96 --- /dev/null +++ b/requirements-docs.txt @@ -0,0 +1,3 @@ +# Pinned to a minor range so a docs build is reproducible. mkdocs-material is +# MIT (verified against PyPI classifiers for 9.5, 9.6, 9.7.0 and 9.7.7). +mkdocs-material>=9.7.7,<9.8 diff --git a/scripts/check_docs_site.py b/scripts/check_docs_site.py new file mode 100644 index 0000000..1253fa7 --- /dev/null +++ b/scripts/check_docs_site.py @@ -0,0 +1,111 @@ +"""Assert the built docs site actually contains its documents. + +`mkdocs build --strict` passing does not mean the site has content in it. This +project's site was green while publishing empty pages: every one of its nine +`--8<--` snippet includes used a `../` path, which pymdownx refuses because it +escapes `base_path`, and `check_paths: false` made that refusal silent. The +Architecture page shipped with 68 words of navigation chrome and none of +ARCHITECTURE.md. + +A build that succeeds while producing nothing is the same defect this +repository has fixed twice elsewhere -- a CI step named for a check it never +performed. So the site is checked for content, not just for exit status. + + python scripts/check_docs_site.py # build, then verify + python scripts/check_docs_site.py --site DIR # verify an existing build +""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + +#: A page carrying only navigation chrome lands around 60-70 words. +#: +#: Two thresholds rather than one, because a flat number is the wrong +#: instrument: api-verity-lab has legitimately terse pages (docs/sdk.md is 18 +#: lines, mostly a code block) that a single 120-word bar reported as broken. +#: The defect being guarded is an *include* resolving to nothing, so +#: include-backed pages get the strict bar and everything else only has to be +#: non-blank. +MIN_WORDS_INCLUDED = 120 +MIN_WORDS_ANY = 40 + +TAG = re.compile(r"<[^>]+>") + + +def words_in(html: str) -> int: + body = html.split("", 1)[0] + return len(TAG.sub(" ", body).split()) + + +def build(into: Path) -> None: + result = subprocess.run( # noqa: S603 - fixed argv, no shell, no user input + [sys.executable, "-m", "mkdocs", "build", "--strict", "--site-dir", str(into)], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + print(result.stderr or result.stdout, file=sys.stderr) + raise SystemExit("mkdocs build failed") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--site", type=Path, default=None) + args = parser.parse_args() + + with tempfile.TemporaryDirectory() as tmp: + site = args.site or Path(tmp) / "site" + if args.site is None: + build(site) + + pages = sorted(site.rglob("index.html")) + if not pages: + print("no pages were built", file=sys.stderr) + return 1 + + # Which source pages are built from a snippet include. + included = { + path.stem + for path in (ROOT / "docs").rglob("*.md") + if "--8<--" in path.read_text(encoding="utf-8", errors="replace") + } + + thin: list[tuple[str, int, str]] = [] + for page in pages: + name = page.parent.relative_to(site).as_posix() or "index" + if name.startswith(("assets", "search")): + continue + count = words_in(page.read_text(encoding="utf-8", errors="replace")) + stem = name.rsplit("/", 1)[-1] or "index" + is_included = stem in included + floor = MIN_WORDS_INCLUDED if is_included else MIN_WORDS_ANY + if count < floor: + why = "include resolved to nothing" if is_included else "page is blank" + thin.append((name, count, why)) + + if thin: + print( + f"{len(thin)} page(s) built with almost no content -- a snippet " + "include is probably resolving to nothing:", + file=sys.stderr, + ) + for name, count, why in thin: + print(f" {name}: {count} words -- {why}", file=sys.stderr) + return 1 + + print(f"ok {len(pages)} pages built, all carrying real content") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())