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())