diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 876cb937..513356e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,6 +80,16 @@ jobs: - name: Website build (Justfile) run: just ci-website + - name: Route-parity check (#387) + # The deployed site reflects main, not a PR — only run the parity + # check (built manifest vs deployed sitemap + 404 probe) on pushes + # to main. A PR that adds new routes would false-positive since they + # aren't deployed yet. + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + env: + QUON_SITE_URL: https://quon.arnabg.me + run: just ci-website-routes + tooling: name: quonfmt · quonlint · LSP smoke runs-on: ubuntu-latest diff --git a/Justfile b/Justfile index 267e2873..62355e00 100644 --- a/Justfile +++ b/Justfile @@ -310,6 +310,15 @@ ci-fuzz: set -euo pipefail cargo +nightly build --manifest-path mlir_bridge/fuzz/Cargo.toml +# Release-time route-parity check (#387): compares the built sitemap against +# the deployed public route set and fails on missing, unexpected, or 404 +# routes. Run after `ci-website` (needs website/dist/). The deployed site +# reflects main, so CI only runs this on pushes to main — a PR that adds new +# routes would false-positive (they aren't deployed yet). Override the +# deployed origin with QUON_SITE_URL. +ci-website-routes: + node scripts/check-doc-routes.mjs + # --------------------------------------------------------------------------- # QEC benchmarks (#254 / ADR-0023) — local convenience recipes # CI smoke + axis coverage live in `python/test_quon_qec_benchmarks.py` diff --git a/docs/agents/validation.md b/docs/agents/validation.md index 90e4a781..be64d759 100644 --- a/docs/agents/validation.md +++ b/docs/agents/validation.md @@ -26,6 +26,7 @@ Static analysis and refinement-type checks for the Quon workspace. | `just ci-rustdoc` | Workspace Rustdoc with warnings denied (`RUSTDOCFLAGS="-D warnings" cargo doc --workspace --exclude flux_verify --no-deps`); unresolved intra-doc links, private-item references, output collisions, and accidental citation links all fail (#406). Also run as a step in `just ci-rust`. | | `just ci-docs-assert` | `./scripts/assert-validation-docs.sh` — stale-claim anchors **plus** the documentation quality-gate corpus validator (`scripts/assert-docs-corpus.py`, #377). See [doc-quality.md](./doc-quality.md) for the contract and the `docs/doc-manifest.yaml` corpus. | | `just ci-website` | Starlight `pnpm build` under `website/` | +| `just ci-website-routes` | Route-parity check (`scripts/check-doc-routes.mjs`, #387); run after `ci-website` | Inside Devbox: `devbox run -- just ` (or `just` after `devbox shell`). @@ -37,6 +38,8 @@ This table is an adapter of the **Justfile** recipes invoked by `.github/workflo | -------- | ------- | --------- | | [ci.yml](../../.github/workflows/ci.yml) `rust` | every push and PR | `just ci-rust`: fmt, clippy, workspace Rustdoc with warnings denied (`ci-rustdoc`, #406), release build (+ examples for lit oracles), `cargo test --workspace --exclude flux_verify` with `QUON_REQUIRE_LIT` so [`quonc/tests/lit.rs`](../../quonc/tests/lit.rs) hard-fails without lit/FileCheck/oracles, and [`quonc/tests/samples_catalog.rs`](../../quonc/tests/samples_catalog.rs) lints `samples/catalog.yaml` and typechecks every `ci: smoke` entry with the debug `quonc` this same `cargo test` builds (ADR-0025 / #185) — the RAP Table I preflight test (#111) runs here too, while the full `rap_table_i --include-ignored` metrics dump is **local-only** (`just rap-table-i`, not invoked by this or any other CI job): pre-#297 its routing-aware A* peaked ~17.5 GB RSS and OOM'd GitHub's 16 GB hosted runners; #297's heuristic search dropped that to ~64 MB, but the recipe has not been re-wired into CI since (documented follow-up, not done here or in #306) — see `docs/neutral_atom/rap_table_i_methodology.md`'s "Runtime / CI wiring" correction; then Qiskit Aer: `test/verify/{bell,teleport,bernstein_vazirani,routing,grover,qft,ising,qaoa,shor}.py` with `QUONC=target/release/quonc`, then QEC Python smokes (`test_qec_stim_smoke`, `test_quon_qec_sinter`, `test_quon_qec_benchmarks` / #254). | | [ci.yml](../../.github/workflows/ci.yml) `docs` | every push and PR | `just ci-docs-assert` + `just ci-website` | +| [ci.yml](../../.github/workflows/ci.yml) `rust` | every push and PR | `just ci-rust`: fmt, clippy, release build (+ examples for lit oracles), `cargo test --workspace --exclude flux_verify` with `QUON_REQUIRE_LIT` so [`quonc/tests/lit.rs`](../../quonc/tests/lit.rs) hard-fails without lit/FileCheck/oracles, and [`quonc/tests/samples_catalog.rs`](../../quonc/tests/samples_catalog.rs) lints `samples/catalog.yaml` and typechecks every `ci: smoke` entry with the debug `quonc` this same `cargo test` builds (ADR-0025 / #185) — the RAP Table I preflight test (#111) runs here too, while the full `rap_table_i --include-ignored` metrics dump is **local-only** (`just rap-table-i`, not invoked by this or any other CI job): pre-#297 its routing-aware A* peaked ~17.5 GB RSS and OOM'd GitHub's 16 GB hosted runners; #297's heuristic search dropped that to ~64 MB, but the recipe has not been re-wired into CI since (documented follow-up, not done here or in #306) — see `docs/neutral_atom/rap_table_i_methodology.md`'s "Runtime / CI wiring" correction; then Qiskit Aer: `test/verify/{bell,teleport,bernstein_vazirani,routing,grover,qft,ising,qaoa,shor}.py` with `QUONC=target/release/quonc`, then QEC Python smokes (`test_qec_stim_smoke`, `test_quon_qec_sinter`, `test_quon_qec_benchmarks` / #254). | +| [ci.yml](../../.github/workflows/ci.yml) `docs` | every push and PR | `just ci-docs-assert` + `just ci-website` (build); on `main` pushes also `just ci-website-routes` (route-parity check, #387) | | [ci.yml](../../.github/workflows/ci.yml) `tooling` | every push and PR | `just ci-tooling`: `quonfmt --check`, `quonlint`, `quon_lsp` smoke on CI corpus | | [release.yml](../../.github/workflows/release.yml) | tags `v*` (+ manual dry-run) | `devbox run release` — static MLIR/LLVM + release-built static libz3; link audit; upload `quon-{version}-{arch}-{os}.tar.gz` to GitHub Releases | | [taskless.yml](../../.github/workflows/taskless.yml) | every PR (diff-scoped); push to `main` (full) | `@taskless/cli check` (Node 22+) | @@ -196,3 +199,41 @@ links, and commands — is mechanically validated by corpus lives in `docs/doc-manifest.yaml`; the four-way policy (executable vs generated vs stale vs illustrative) and the contributor workflow for adding a checked example are in [doc-quality.md](./doc-quality.md). +## Documentation route parity (#387) + +A documentation release must not silently publish a different route tree or navigation configuration from the reviewed build. The checked-in Starlight site declares a Learning track, but a stale deployment can omit it and the intended routes return 404. The `ci-website-routes` recipe (`just ci-website-routes`, run by the `ci.yml` `docs` job on `main` pushes) enforces parity as a release contract. The deployed site reflects `main`, so the parity check only runs on `main` pushes — a PR that adds new routes would false-positive since they aren't deployed yet. The `ci-website` recipe (build only) remains the PR gate. + +### What the check does + +`scripts/check-doc-routes.mjs` runs after `pnpm build` and: + +1. Reads the built route manifest from `website/dist/sitemap-0.xml`. +2. Maps each route to its originating source file under `website/src/content/docs/`. +3. Fetches the deployed public route set from the live sitemap (`sitemap-index.xml` → `sitemap-0.xml`). +4. Fails on: + - **missing** routes — built but absent from the deployed sitemap + - **unexpected** routes — deployed but absent from the built manifest + - **404 routes** — built routes that return HTTP 404 on the live site +5. Reports the originating source route (`website/src/content/docs/.md(x)`) for every mismatch. + +Override the deployed origin with `QUON_SITE_URL` (default: `https://quon.arnabg.me`). + +### Recovery procedure + +When `check-doc-routes.mjs` fails, the build/publish input to fix is always the checked-in source tree — never patch the deployment directly. + +1. **Confirm the build.** `cd website && pnpm build` must succeed and `website/dist/sitemap-0.xml` must list every intended route, including the Learning track (`/learn/`, `/learn/01-hello-quon/`, … `/learn/06-oracles-algorithms/`). +2. **If routes are missing from the build**, fix the checked-in `website/src/content/docs/` files or the sidebar entries in `website/astro.config.mjs`. The sidebar config is the single source of truth for navigation groups. +3. **If the build is correct but the deployed site omits routes**, the published artifact is stale or corrupt. Redeploy `website/dist/` from the same commit that passed this check: + ```bash + cd website && pnpm install --frozen-lockfile && pnpm build + # Publish website/dist/ to the hosting provider (GitHub Pages, + # Cloudflare Pages, etc.) from this commit. + ``` +4. **Re-run the check** until it passes: + ```bash + node scripts/check-doc-routes.mjs + # Or, if the Astro build is temporarily broken on main, audit the + # deployed site against a known-good manifest: + node scripts/check-doc-routes.mjs --expected path/to/good-sitemap.xml + ``` diff --git a/scripts/check-doc-routes.mjs b/scripts/check-doc-routes.mjs new file mode 100755 index 00000000..5a41f5bf --- /dev/null +++ b/scripts/check-doc-routes.mjs @@ -0,0 +1,328 @@ +#!/usr/bin/env node +// Release-time route-parity check for the Quon documentation site (#387). +// +// The checked-in Starlight site declares a Learning track and its lesson +// routes, but a deployment can silently publish a different route tree — the +// deployed navigation omits the track and the intended routes return 404. +// This script makes deployment parity a release contract: it compares the +// route manifest produced by `astro build` (the reviewed artifact) against +// the deployed public route set (the live sitemap) and fails on: +// +// - missing routes — built but absent from the deployed sitemap +// - unexpected routes — deployed but absent from the built manifest +// - 404 routes — built routes that return HTTP 404 on the live site +// +// Every mismatch reports the originating source route (the checked-in +// `website/src/content/docs/.md(x)` file) or the sidebar entry that +// declared it, so a failed release points directly at the build/publish input +// to fix. +// +// Usage: +// node scripts/check-doc-routes.mjs [options] +// +// Options (all optional; defaults suit CI and local use): +// --site Deployed site origin (default: https://quon.arnabg.me +// or QUON_SITE_URL env var) +// --dist Built site directory (default: website/dist) +// --src Content source dir (default: website/src/content/docs) +// --no-probe Skip live 404 probing (still compares sitemaps) +// --expected Override built manifest with a sitemap XML file +// (useful when the Astro build is broken on main and you +// need to audit the deployed site against a known-good +// manifest) +// +// Requires Node 18+ (global fetch). Run after `pnpm build` in website/. +// +// Recovery procedure (printed on failure): +// 1. Confirm the built manifest: `cd website && pnpm build` must succeed and +// `website/dist/sitemap-0.xml` must list every intended route, including +// the Learning track (`/learn/`, `/learn/01-hello-quon/`, …). +// 2. If the build is missing routes, the source tree or sidebar config in +// `website/astro.config.mjs` is the build/publish input — fix the +// checked-in `website/src/content/docs/` files or sidebar entries, never +// the deployment directly. +// 3. If the build is correct but the deployed site omits routes, the +// published artifact is stale or corrupt. Redeploy the artifact produced +// by `pnpm build` (the `website/dist/` directory) from the same commit +// that passed this check: +// cd website && pnpm install --frozen-lockfile && pnpm build +// # Publish website/dist/ to the hosting provider (GitHub Pages, +// # Cloudflare Pages, etc.) from this commit. +// 4. Re-run this check against the redeployed site until it passes. +// +// See docs/agents/validation.md → "Documentation route parity (#387)". + +import { readdir, readFile, stat } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { join, relative, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { dirname } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, '..'); + +// ── argument parsing ──────────────────────────────────────────────────────── + +function parseArgs(argv) { + const opts = { + site: process.env.QUON_SITE_URL || 'https://quon.arnabg.me', + dist: join(ROOT, 'website', 'dist'), + src: join(ROOT, 'website', 'src', 'content', 'docs'), + probe: true, + expected: null, + }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + switch (a) { + case '--site': opts.site = argv[++i]; break; + case '--dist': opts.dist = argv[++i]; break; + case '--src': opts.src = argv[++i]; break; + case '--expected': opts.expected = argv[++i]; break; + case '--no-probe': opts.probe = false; break; + case '-h': case '--help': + console.log(`Usage: check-doc-routes.mjs [--site URL] [--dist DIR] [--src DIR] [--no-probe] [--expected FILE]`); + process.exit(0); + default: + if (a.startsWith('--')) { + console.error(`check-doc-routes: unknown option ${a}`); + process.exit(2); + } + } + } + return opts; +} + +// ── helpers ───────────────────────────────────────────────────────────────── + +/** Recursively collect files under dir, returning absolute paths. */ +async function walk(dir) { + const out = []; + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + return out; + } + for (const e of entries) { + const full = join(dir, e.name); + if (e.isDirectory()) { + out.push(...(await walk(full))); + } else { + out.push(full); + } + } + return out; +} + +/** + * Build a map of route path → source file by walking the content directory. + * Starlight emits `/[slug]/` for every `src/content/docs/[slug].md(x)`. + * index.mdx → / + * learn/index.mdx → /learn/ + * learn/01-hello-quon.mdx → /learn/01-hello-quon/ + */ +async function buildSourceMap(srcDir) { + const files = (await walk(srcDir)).filter((f) => /\.(md|mdx)$/i.test(f)); + const map = new Map(); + for (const f of files) { + const rel = relative(srcDir, f).replace(/\\/g, '/'); // e.g. learn/01-hello-quon.mdx + let slug = rel.replace(/\.(md|mdx)$/i, ''); + // A trailing `index` maps to the directory route: learn/index → /learn/ + if (slug.endsWith('/index')) slug = slug.slice(0, -'/index'.length); + const route = slug === 'index' ? '/' : '/' + slug + '/'; + map.set(route, f); + } + return map; +} + +/** Parse a sitemap XML string and return a sorted array of route paths. */ +function parseSitemap(xml, siteOrigin) { + const routes = new Set(); + const re = /([^<]+)<\/loc>/g; + let m; + while ((m = re.exec(xml)) !== null) { + let url = m[1].trim(); + // Normalise to a site-relative route path. + try { + const u = new URL(url); + url = u.pathname; + } catch { + // Not a full URL — strip origin if prefixed. + url = url.replace(new RegExp('^' + escapeRegex(siteOrigin)), ''); + } + if (!url.startsWith('/')) url = '/' + url; + // Astro/Starlight emits trailing slashes for pages. + if (url !== '/' && !url.endsWith('/')) url += '/'; + routes.add(url); + } + return [...routes].sort(); +} + +function escapeRegex(s) { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** Fetch text, following up to 3 redirects. */ +async function fetchText(url, { redirects = 3 } = {}) { + const res = await fetch(url, { redirect: 'manual' }); + if (res.status >= 300 && res.status < 400 && res.headers.get('location') && redirects > 0) { + const loc = new URL(res.headers.get('location'), url).href; + return fetchText(loc, { redirects: redirects - 1 }); + } + if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${url}`); + return res.text(); +} + +/** HEAD-probe a route; return the HTTP status code (or 0 on network error). */ +async function probeRoute(siteOrigin, route) { + const url = siteOrigin.replace(/\/$/, '') + route; + try { + const res = await fetch(url, { method: 'HEAD', redirect: 'follow' }); + return res.status; + } catch { + // Some hosts reject HEAD; fall back to GET. + try { + const res = await fetch(url, { redirect: 'follow' }); + return res.status; + } catch { + return 0; + } + } +} + +/** Resolve the deployed sitemap URL list from the live site. */ +async function fetchDeployedRoutes(siteOrigin) { + // Starlight/Astro emits sitemap-index.xml → sitemap-0.xml. + const indexUrl = siteOrigin.replace(/\/$/, '') + '/sitemap-index.xml'; + let routes = []; + try { + const idx = await fetchText(indexUrl); + const subSitemaps = parseSitemap(idx, siteOrigin); + for (const sm of subSitemaps) { + const smUrl = /^https?:/.test(sm) + ? sm + : siteOrigin.replace(/\/$/, '') + (sm.startsWith('/') ? sm : '/' + sm); + const body = await fetchText(smUrl); + routes.push(...parseSitemap(body, siteOrigin)); + } + } catch { + // Fall back to sitemap-0.xml directly. + const body = await fetchText(siteOrigin.replace(/\/$/, '') + '/sitemap-0.xml'); + routes = parseSitemap(body, siteOrigin); + } + return [...new Set(routes)].sort(); +} + +// ── main ──────────────────────────────────────────────────────────────────── + +async function main() { + const opts = parseArgs(process.argv.slice(2)); + const failures = []; + + // 1. Built route manifest. + let builtRoutes; + let sourceMap; + if (opts.expected) { + const xml = await readFile(opts.expected, 'utf8'); + builtRoutes = parseSitemap(xml, opts.site); + sourceMap = await buildSourceMap(opts.src); + } else { + const sitemapPath = join(opts.dist, 'sitemap-0.xml'); + if (!existsSync(sitemapPath)) { + console.error(`check-doc-routes: built sitemap not found at ${sitemapPath}`); + console.error(' Run `pnpm build` in website/ first, or pass --expected .'); + process.exit(1); + } + const xml = await readFile(sitemapPath, 'utf8'); + builtRoutes = parseSitemap(xml, opts.site); + sourceMap = await buildSourceMap(opts.src); + } + + if (builtRoutes.length === 0) { + console.error('check-doc-routes: built manifest is empty — refusing to pass vacuously.'); + process.exit(1); + } + + console.log(`check-doc-routes: built manifest has ${builtRoutes.length} route(s).`); + for (const r of builtRoutes) { + const src = sourceMap.get(r) || '(no source file)'; + console.log(` built ${r} ← ${relative(ROOT, src)}`); + } + + // 2. Deployed route set. + let deployedRoutes; + try { + deployedRoutes = await fetchDeployedRoutes(opts.site); + } catch (e) { + console.error(`check-doc-routes: cannot fetch deployed sitemap from ${opts.site}: ${e.message}`); + process.exit(1); + } + console.log(`check-doc-routes: deployed sitemap has ${deployedRoutes.length} route(s).`); + + // 3. Compare. + const builtSet = new Set(builtRoutes); + const deployedSet = new Set(deployedRoutes); + + const missing = builtRoutes.filter((r) => !deployedSet.has(r)); + const unexpected = deployedRoutes.filter((r) => !builtSet.has(r)); + + if (missing.length) { + console.error('\ncheck-doc-routes: MISSING routes (built but not deployed):'); + for (const r of missing) { + const src = sourceMap.get(r) || '(no source file)'; + console.error(` MISSING ${r} ← ${relative(ROOT, src)}`); + } + failures.push(...missing.map((r) => `missing:${r}`)); + } + + if (unexpected.length) { + console.error('\ncheck-doc-routes: UNEXPECTED routes (deployed but not built):'); + for (const r of unexpected) { + console.error(` UNEXPECTED ${r}`); + } + failures.push(...unexpected.map((r) => `unexpected:${r}`)); + } + + // 4. 404 probe: confirm built routes are live and reachable. + if (opts.probe) { + console.log('\ncheck-doc-routes: probing built routes for 404s …'); + const notFound = []; + for (const r of builtRoutes) { + const status = await probeRoute(opts.site, r); + if (status === 404) { + const src = sourceMap.get(r) || '(no source file)'; + console.error(` 404 ${r} ← ${relative(ROOT, src)}`); + notFound.push(r); + } + } + if (notFound.length) { + failures.push(...notFound.map((r) => `404:${r}`)); + } + } + + // 5. Verdict. + if (failures.length) { + console.error(`\ncheck-doc-routes: FAILED — ${failures.length} mismatch(es).`); + console.error('\nRecovery procedure:'); + console.error(' 1. Confirm the build: `cd website && pnpm build` must succeed and'); + console.error(' website/dist/sitemap-0.xml must list every intended route.'); + console.error(' 2. If routes are missing from the build, fix the checked-in source'); + console.error(' files in website/src/content/docs/ or sidebar entries in'); + console.error(' website/astro.config.mjs — never patch the deployment directly.'); + console.error(' 3. If the build is correct but the deployed site omits routes, the'); + console.error(' published artifact is stale. Redeploy website/dist/ from the'); + console.error(' commit that passed this check:'); + console.error(' cd website && pnpm install --frozen-lockfile && pnpm build'); + console.error(' # Publish website/dist/ to the hosting provider.'); + console.error(' 4. Re-run: node scripts/check-doc-routes.mjs'); + console.error(`\nSee docs/agents/validation.md → "Documentation route parity (#387)".`); + process.exit(1); + } + + console.log('\ncheck-doc-routes: OK — deployed site matches built route manifest.'); +} + +main().catch((e) => { + console.error(`check-doc-routes: fatal — ${e.message}`); + process.exit(1); +});