Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,9 +134,9 @@ merge the docs page first, then update the backend's checkout pin in the PR
that adds the code. The full check fails when:

- `PROBLEM_TYPE_BASE` is not `https://docs.cortex.foundation/problems`
- an `ErrorCode` is missing its `/problems/{code}` page, or a page documents a code the API does not emit
- a code in `ERROR_CODES` (`server/src/core/error.ts`) is missing its `/problems/{code}` page, or a page documents a code the API does not emit
- a `docs.json` navigation slug, group root, navbar or footer href, redirect destination, or internal link has no matching MDX page
- a documented `/v1/…` path is not registered in `crates/cortex-api/src/router.rs`
- a documented `/v1/…` path is not registered by a route module under `server/src/api`
- a screenshot lacks alt text, references a missing or unsafe local file, uses an unsupported format or embeds remote media
- a page lacks a title, description, or icon, repeats another page's title, or has a description over 160 characters
- navigation is not `navigation.tabs` with a tab for each of Chat, Code, Bot, CLI, Design, and Security, each tab and group carrying an icon
Expand Down
69 changes: 41 additions & 28 deletions scripts/check-docs-site.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*
* `PROBLEM_TYPE_BASE` is wire contract: every error the API returns carries a
* `type` URL under `https://docs.cortex.foundation/problems`, so each
* `ErrorCode` needs a page there, and no page may document a code the API does
* error code in `ERROR_CODES` needs a page there, and no page may document a code the API does
* not emit. Documenting a `/v1/…` path the router does not register is the
* same class of silent decay.
*
Expand Down Expand Up @@ -59,19 +59,17 @@ function walk(dir) {
return out;
}

function rustErrorCodes(source) {
const block = /pub const fn as_str\(self\)[\s\S]*?match self \{([\s\S]*?)\n \}/.exec(
source,
);
function serverErrorCodes(source) {
const block = /export const ERROR_CODES = \{([\s\S]*?)\n\}/.exec(source);
if (block === null) {
fail('could not find `ErrorCode::as_str` in crates/cortex-core/src/error.rs');
fail('could not find `ERROR_CODES` in server/src/core/error.ts');
return [];
}
return [...block[1].matchAll(/=> "([a-z_]+)"/g)].map((m) => m[1]);
return [...block[1].matchAll(/^\s+([a-z_]+):/gm)].map((m) => m[1]);
}

function rustProblemBase(source) {
return /pub const PROBLEM_TYPE_BASE: &str = "([^"]+)";/.exec(source)?.[1] ?? null;
function serverProblemBase(source) {
return /export const PROBLEM_TYPE_BASE = "([^"]+)";/.exec(source)?.[1] ?? null;
}

function tsProblemBase(source) {
Expand Down Expand Up @@ -262,12 +260,20 @@ function internalHrefs(text) {
return out;
}

function routerPaths(source) {
/**
* `routes(r)` in server/src/api registers under `/v1`; `operationalRoutes`
* (health probes, `/internal/…`) stay bare. Literals already starting with
* `/v1` are `adminRoutes` on the private listener and are not public API.
*/
function routerPaths(sources) {
const registered = new Set();
for (const m of source.matchAll(/\.route\(\s*"([^"]+)"/g)) {
const path = m[1];
if (OPERATIONAL.has(path)) registered.add(path);
else registered.add(`/v1${path}`);
for (const source of sources) {
for (const m of source.matchAll(/\.(?:get|post|put|patch|delete)\(\s*"(\/[^"]*)"/g)) {
const path = m[1];
if (path === '/v1' || path.startsWith('/v1/')) continue;
if (OPERATIONAL.has(path) || path.startsWith('/internal/')) registered.add(path);
else registered.add(`/v1${path}`);
}
}
return registered;
}
Expand Down Expand Up @@ -295,26 +301,33 @@ function documentedV1Paths(text) {
return found;
}

const errorRs = BACKEND === null ? '' : read('crates/cortex-core/src/error.rs', BACKEND);
const errorSrc = BACKEND === null ? '' : read('server/src/core/error.ts', BACKEND);
const errorsTs = BACKEND === null ? '' : read('packages/api-types/src/errors.ts', BACKEND);
const routerRs = BACKEND === null ? '' : read('crates/cortex-api/src/router.rs', BACKEND);
const API_DIR = 'server/src/api';
if (BACKEND !== null && !existsSync(join(BACKEND, API_DIR))) fail(`missing ${API_DIR}`);
const routerSources =
BACKEND === null
? []
: walk(join(BACKEND, API_DIR))
.filter((path) => path.endsWith('.ts'))
.map((path) => readFileSync(path, 'utf8'));
const docsRoot = ROOT;

if (BACKEND !== null) {
const rustBase = rustProblemBase(errorRs);
const serverBase = serverProblemBase(errorSrc);
const tsBase = tsProblemBase(errorsTs);
if (rustBase === null) {
fail('could not find `PROBLEM_TYPE_BASE` in crates/cortex-core/src/error.rs');
} else if (rustBase !== EXPECTED_BASE) {
if (serverBase === null) {
fail('could not find `PROBLEM_TYPE_BASE` in server/src/core/error.ts');
} else if (serverBase !== EXPECTED_BASE) {
fail(
`PROBLEM_TYPE_BASE in error.rs is ${rustBase}; user-facing problem URIs must be ${EXPECTED_BASE}`,
`PROBLEM_TYPE_BASE in server/src/core/error.ts is ${serverBase}; user-facing problem URIs must be ${EXPECTED_BASE}`,
);
}
if (tsBase === null) {
fail('could not find `PROBLEM_TYPE_BASE` in packages/api-types/src/errors.ts');
} else if (tsBase !== rustBase && rustBase !== null) {
} else if (tsBase !== serverBase && serverBase !== null) {
fail(
`PROBLEM_TYPE_BASE: error.rs serves ${rustBase} and api-types claims ${tsBase}`,
`PROBLEM_TYPE_BASE: server/src/core/error.ts serves ${serverBase} and api-types claims ${tsBase}`,
);
} else if (tsBase !== EXPECTED_BASE) {
fail(
Expand All @@ -328,13 +341,13 @@ const problemPages = existsSync(problemDir)
? readdirSync(problemDir).filter((name) => name.endsWith('.mdx') && name !== 'index.mdx')
: [];
const pageCodes = new Set(problemPages.map((name) => name.replace(/\.mdx$/, '')));
const codes = BACKEND === null ? [...pageCodes] : rustErrorCodes(errorRs);
const codes = BACKEND === null ? [...pageCodes] : serverErrorCodes(errorSrc);
if (codes.length === 0) fail('no problem codes found');

for (const code of codes) {
const rel = `problems/${code}.mdx`;
if (!pageCodes.has(code)) {
fail(`ErrorCode \`${code}\` has no Mintlify page at ${rel}`);
fail(`ERROR_CODES \`${code}\` has no Mintlify page at ${rel}`);
continue;
}
const page = read(rel);
Expand All @@ -346,7 +359,7 @@ for (const code of codes) {
for (const extra of pageCodes) {
if (!codes.includes(extra)) {
fail(
`problems/${extra}.mdx documents a code that is not in ErrorCode::as_str`,
`problems/${extra}.mdx documents a code that is not in ERROR_CODES (server/src/core/error.ts)`,
);
}
}
Expand All @@ -372,7 +385,7 @@ for (const rel of FORBIDDEN_AUTH_PAGES) {
}
}

const registered = routerPaths(routerRs);
const registered = routerPaths(routerSources);
const registeredNorm = new Set([...registered].map(normalizePath));
const docsFiles = walk(docsRoot).filter((path) => /\.(mdx|md|json)$/.test(path));
const titles = new Map();
Expand All @@ -397,7 +410,7 @@ for (const file of docsFiles) {
const norm = normalizePath(path);
if (!registeredNorm.has(norm)) {
fail(
`${rel} documents \`${path}\`, which is not registered in crates/cortex-api/src/router.rs`,
`${rel} documents \`${path}\`, which is not registered in server/src/api`,
);
}
}
Expand Down
64 changes: 42 additions & 22 deletions scripts/tests/check-docs-site.test.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Holds scripts/check-docs-site.mjs. No Mintlify CLI: a throwaway tree with a
# fake ErrorCode, a fake router, and a couple of MDX files is enough to prove
# fake ERROR_CODES, fake route modules, and a couple of MDX files is enough to prove
# a matching site passes and that a wrong domain, a missing page, a dead
# navigation entry, an unsafe image, a leaked auth internal, or an invented /v1 path
# fails.
Expand All @@ -18,31 +18,36 @@ trap cleanup EXIT
seed() {
local dest="$1"
mkdir -p \
"$dest/crates/cortex-core/src" \
"$dest/crates/cortex-api/src" \
"$dest/server/src/core" \
"$dest/server/src/api" \
"$dest/packages/api-types/src" \
"$dest/site/problems" \
"$dest/site/logo"
cat > "$dest/crates/cortex-core/src/error.rs" <<'RS'
pub const PROBLEM_TYPE_BASE: &str = "https://docs.cortex.foundation/problems";

impl ErrorCode {
pub const fn as_str(self) -> &'static str {
match self {
Self::NotFound => "not_found",
Self::Internal => "internal",
}
}
}
RS
cat > "$dest/server/src/core/error.ts" <<'TS'
export const PROBLEM_TYPE_BASE = "https://docs.cortex.foundation/problems";

export const ERROR_CODES = {
not_found: [404, "Not found"],
internal: [500, "Something went wrong on our side"],
} as const satisfies Record<string, readonly [number, string]>;
TS
cat > "$dest/packages/api-types/src/errors.ts" <<'TS'
export const PROBLEM_TYPE_BASE = 'https://docs.cortex.foundation/problems' as const;
TS
cat > "$dest/crates/cortex-api/src/router.rs" <<'RS'
.route("/healthz", get(health::liveness))
.route("/conversations", get(list))
.route("/conversations/{id}", get(get).patch(patch))
RS
cat > "$dest/server/src/api/index.ts" <<'TS'
publicRouter.get("/healthz", liveness).get("/readyz", readiness).get("/startupz", startup);
TS
cat > "$dest/server/src/api/conversations.ts" <<'TS'
export function routes(r: Router<Ctx>): void {
r.get("/conversations", list);
r.get("/conversations/{id}", get).patch("/conversations/{id}", patch);
}
TS
cat > "$dest/server/src/api/admin-insights.ts" <<'TS'
export function adminRoutes(r: Router<Ctx>): void {
r.get("/v1/admin/insights/overview", overview);
}
TS
printf '<svg xmlns="http://www.w3.org/2000/svg"></svg>' > "$dest/site/favicon.svg"
printf '<svg xmlns="http://www.w3.org/2000/svg"></svg>' > "$dest/site/logo/light.svg"
printf '<svg xmlns="http://www.w3.org/2000/svg"></svg>' > "$dest/site/logo/dark.svg"
Expand Down Expand Up @@ -124,14 +129,29 @@ out="$(cd "$tmp" && CORTEX_CHECK_ROOT="$happy/site" node "$script" 2>&1)" ||
if CORTEX_CHECK_ROOT="$happy/site" node "$script" "$tmp/absent-backend" >/dev/null 2>&1; then
fail "an explicitly requested missing backend must fail"
fi
if CORTEX_CHECK_ROOT="$tmp/absent-docs" node "$script" "$happy" >/dev/null 2>&1; then
fail "a missing docs checkout must fail"
fi

# Wrong domain on the Rust constant.
# Wrong domain on the server constant.
wrong="$tmp/wrong-base"
seed "$wrong"
sed -i 's|https://docs.cortex.foundation/problems|https://docs.cortex.sh/problems|' \
"$wrong/crates/cortex-core/src/error.rs"
"$wrong/server/src/core/error.ts"
must_fail "$wrong" "docs.cortex.foundation"

# A documented code the server no longer serves.
dropped="$tmp/dropped-code"
seed "$dropped"
sed -i '/^ internal:/d' "$dropped/server/src/core/error.ts"
must_fail "$dropped" "problems/internal.mdx documents a code that is not in ERROR_CODES"

# Admin-listener routes are not public API.
adminpath="$tmp/admin-path"
seed "$adminpath"
printf '\n`GET` `/v1/admin/insights/overview`\n' >> "$adminpath/site/problems/not_found.mdx"
must_fail "$adminpath" "admin/insights/overview"

# Missing problem page.
missing="$tmp/missing-page"
seed "$missing"
Expand Down
Loading