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