Files
elizaos--eliza/packages/scripts/launch-qa/check-docs.mjs
T
wehub-resource-sync 426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

551 lines
15 KiB
JavaScript

#!/usr/bin/env node
// Runs launch QA launch qa check docs automation for release-readiness checks.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const here = path.dirname(fileURLToPath(import.meta.url));
const defaultRepoRoot = path.resolve(here, "../../..");
const MARKDOWN_LINK_RE = /!?\[([^\]\n]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
const HTML_LINK_RE = /<(?:a|img)\b[^>]*(?:href|src)=["']([^"']+)["'][^>]*>/gi;
const BUN_RUN_RE =
/(?:^|[;&|(`\s])(?:(?:cd\s+([^\s;&|`]+)\s*&&\s*)?bun\s+run(?:\s+--cwd\s+([^\s;&|`]+))?\s+([A-Za-z0-9:_-]+))(?=$|[\s`)&;|])/g;
const HEADING_RE = /^(#{1,6})\s+(.+?)\s*#*\s*$/gm;
const EXPLICIT_ANCHOR_RE =
/<(?:a|[^>]+\s)\b(?:name|id)=["']([^"']+)["'][^>]*>/gi;
const SKIP_DIRS = new Set([
".git",
".turbo",
"coverage",
"dist",
"node_modules",
"target",
]);
const ROOT_DOCS = ["README.md", "CONTRIBUTING.md", "SECURITY.md", "WINDOWS.md"];
function rel(repoRoot, filePath) {
return path.relative(repoRoot, filePath).split(path.sep).join("/");
}
function exists(filePath) {
try {
fs.accessSync(filePath);
return true;
} catch {
return false;
}
}
function isDirectory(filePath) {
try {
return fs.statSync(filePath).isDirectory();
} catch {
return false;
}
}
function isFile(filePath) {
try {
return fs.statSync(filePath).isFile();
} catch {
return false;
}
}
function readJson(filePath) {
try {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
} catch {
return null;
}
}
function walkMarkdownFiles(dir) {
if (!exists(dir)) {
return [];
}
const files = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name.startsWith(".") || SKIP_DIRS.has(entry.name)) {
continue;
}
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...walkMarkdownFiles(fullPath));
} else if (
entry.isFile() &&
(entry.name.endsWith(".md") || entry.name.endsWith(".mdx"))
) {
files.push(fullPath);
}
}
return files;
}
function walkReadmes(dir) {
if (!exists(dir)) {
return [];
}
const files = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name.startsWith(".") || SKIP_DIRS.has(entry.name)) {
continue;
}
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...walkReadmes(fullPath));
} else if (entry.isFile() && entry.name === "README.md") {
files.push(fullPath);
}
}
return files;
}
function walkPackageJsons(dir) {
if (!exists(dir)) {
return [];
}
const files = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name.startsWith(".") || SKIP_DIRS.has(entry.name)) {
continue;
}
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...walkPackageJsons(fullPath));
} else if (entry.isFile() && entry.name === "package.json") {
files.push(fullPath);
}
}
return files;
}
function collectDocs(repoRoot, scope = "all") {
const files = new Set();
const dirs =
scope === "launchdocs"
? [
"packages/docs/docs/launchdocs",
"packages/docs/launchdocs",
"packages/docs/launch-resources",
"launchdocs",
]
: scope === "docs"
? ["packages/docs/docs", "packages/docs", "docs"]
: ["packages/docs/docs", "packages/docs", "docs", "launchdocs"];
for (const dirName of dirs) {
for (const filePath of walkMarkdownFiles(path.join(repoRoot, dirName))) {
files.add(filePath);
}
}
if (scope === "launchdocs") {
return [...files].sort((left, right) => left.localeCompare(right));
}
for (const rootDoc of ROOT_DOCS) {
const rootDocPath = path.join(repoRoot, rootDoc);
if (exists(rootDocPath)) {
files.add(rootDocPath);
}
}
if (scope !== "docs") {
const packagesDir = path.join(repoRoot, "packages");
if (exists(packagesDir)) {
for (const readme of walkReadmes(packagesDir)) {
files.add(readme);
}
}
}
return [...files].sort((left, right) => left.localeCompare(right));
}
function collectPackageScripts(repoRoot) {
const scriptsByDir = new Map();
const rootPackage = readJson(path.join(repoRoot, "package.json"));
if (rootPackage?.scripts) {
scriptsByDir.set(repoRoot, rootPackage.scripts);
}
for (const rootName of ["packages", "plugins", "cloud/packages"]) {
const scanRoot = path.join(repoRoot, rootName);
for (const packageJsonPath of walkPackageJsons(scanRoot)) {
const packageJson = readJson(packageJsonPath);
if (packageJson?.scripts) {
scriptsByDir.set(path.dirname(packageJsonPath), packageJson.scripts);
}
}
}
return scriptsByDir;
}
function stripCodeFences(markdown) {
return markdown.replace(/```[\s\S]*?```/g, "");
}
function slugifyHeading(heading) {
return heading
.replace(/<[^>]*>/g, "")
.replace(/[`*_~[\]]/g, "")
.trim()
.toLowerCase()
.replace(/[^\p{L}\p{N}\s-]/gu, "")
.replace(/\s+/g, "-");
}
function anchorsFor(markdown) {
const anchors = new Set();
const slugCounts = new Map();
for (const match of markdown.matchAll(HEADING_RE)) {
const base = slugifyHeading(match[2]);
if (!base) {
continue;
}
const count = slugCounts.get(base) ?? 0;
slugCounts.set(base, count + 1);
anchors.add(count === 0 ? base : `${base}-${count}`);
}
for (const match of markdown.matchAll(EXPLICIT_ANCHOR_RE)) {
anchors.add(match[1]);
}
return anchors;
}
function isExternalTarget(target) {
return /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(target);
}
function normalizeLinkTarget(rawTarget) {
let target = rawTarget.trim();
if (!target || isExternalTarget(target)) {
return null;
}
target = target.replace(/^<|>$/g, "");
const hashIndex = target.indexOf("#");
const filePart = hashIndex === -1 ? target : target.slice(0, hashIndex);
const anchor = hashIndex === -1 ? "" : target.slice(hashIndex + 1);
const cleanFilePart = filePart.split("?")[0];
return {
filePart: decodeURIComponent(cleanFilePart),
anchor: decodeURIComponent(anchor),
};
}
function candidatePaths(basePath) {
const candidates = [basePath];
if (!path.extname(basePath)) {
// packages/docs is a Mintlify site whose pages are `.mdx`; links omit the
// extension, so `.mdx` must be tried alongside `.md` or every link to an
// `.mdx` page (e.g. `/config-schema`, `/user/change-character`) is a false
// "missing file".
candidates.push(`${basePath}.md`, `${basePath}.mdx`);
}
candidates.push(
path.join(basePath, "README.md"),
path.join(basePath, "index.md"),
path.join(basePath, "index.mdx"),
);
return candidates;
}
function firstExistingPath(basePaths) {
for (const basePath of basePaths) {
for (const candidate of candidatePaths(basePath)) {
if (exists(candidate)) {
return candidate;
}
}
}
return null;
}
function docsAliasPaths(repoRoot, filePart) {
const parts = filePart.split(/[\\/]+/).filter(Boolean);
const docsIndex = parts.lastIndexOf("docs");
if (docsIndex === -1) {
return [];
}
const docsRelativeParts = parts.slice(docsIndex + 1);
return [
path.join(repoRoot, "packages", "docs", "docs", ...docsRelativeParts),
path.join(repoRoot, "packages", "docs", ...docsRelativeParts),
];
}
function resolveLinkedFile(repoRoot, fromFile, filePart) {
if (!filePart) {
return fromFile;
}
if (filePart.startsWith("/")) {
const stripped = filePart.replace(/^\/+/, "");
return (
firstExistingPath([
path.join(repoRoot, "packages", "docs", "docs", stripped),
path.join(repoRoot, "packages", "docs", stripped),
path.join(repoRoot, "packages", "docs", "public", stripped),
...docsAliasPaths(repoRoot, stripped),
path.join(repoRoot, "docs", stripped),
path.join(repoRoot, stripped),
]) ?? path.join(repoRoot, "docs", stripped)
);
}
const basePath = path.resolve(path.dirname(fromFile), filePart);
return (
firstExistingPath([basePath, ...docsAliasPaths(repoRoot, filePart)]) ??
basePath
);
}
function resolveAnchorFile(filePath) {
if (!isDirectory(filePath)) {
return filePath;
}
for (const candidate of ["README.md", "index.md"]) {
const candidatePath = path.join(filePath, candidate);
if (isFile(candidatePath)) {
return candidatePath;
}
}
return filePath;
}
function collectLinks(markdown) {
const links = [];
for (const match of markdown.matchAll(MARKDOWN_LINK_RE)) {
if (match[0].startsWith("!")) {
continue;
}
links.push(match[2]);
}
for (const match of markdown.matchAll(HTML_LINK_RE)) {
links.push(match[1]);
}
return links;
}
function inferPackageDir(repoRoot, docFile) {
const relative = rel(repoRoot, docFile);
const match = relative.match(/^packages\/[^/]+\/README\.md$/);
if (!match) {
return repoRoot;
}
return path.join(repoRoot, relative.split("/").slice(0, 2).join("/"));
}
function normalizeCwd(repoRoot, docFile, cwdPart) {
if (!cwdPart) {
return inferPackageDir(repoRoot, docFile);
}
const stripped = cwdPart.replace(/^["']|["']$/g, "");
if (stripped === ".") {
return inferPackageDir(repoRoot, docFile);
}
const docRel = rel(repoRoot, docFile);
const commandBase =
docRel.startsWith("packages/docs/") ||
docRel.startsWith("docs/") ||
docRel.startsWith("launchdocs/")
? repoRoot
: path.dirname(docFile);
return path.resolve(commandBase, stripped);
}
function findNearestScriptDir(cwd, scriptsByDir, repoRoot) {
let current = cwd;
while (current.startsWith(repoRoot)) {
if (scriptsByDir.has(current)) {
return current;
}
const parent = path.dirname(current);
if (parent === current) {
break;
}
current = parent;
}
return repoRoot;
}
function lineForOffset(text, offset) {
return text.slice(0, offset).split("\n").length;
}
function checkLinks({ repoRoot, docFiles, contentByFile }) {
const errors = [];
const anchorCache = new Map();
function getAnchors(filePath) {
if (!anchorCache.has(filePath)) {
const markdown =
contentByFile.get(filePath) ?? fs.readFileSync(filePath, "utf8");
anchorCache.set(filePath, anchorsFor(markdown));
}
return anchorCache.get(filePath);
}
for (const filePath of docFiles) {
const markdown =
contentByFile.get(filePath) ?? fs.readFileSync(filePath, "utf8");
for (const rawTarget of collectLinks(stripCodeFences(markdown))) {
const target = normalizeLinkTarget(rawTarget);
if (!target) {
continue;
}
const linkedFile = resolveLinkedFile(repoRoot, filePath, target.filePart);
if (target.filePart && !exists(linkedFile)) {
errors.push({
type: "missing-file",
file: rel(repoRoot, filePath),
target: rawTarget,
resolved: rel(repoRoot, linkedFile),
message: `missing linked file ${rawTarget}`,
});
continue;
}
if (target.anchor) {
const anchorFile = target.filePart
? resolveAnchorFile(linkedFile)
: filePath;
if (isFile(anchorFile) && !getAnchors(anchorFile).has(target.anchor)) {
errors.push({
type: "missing-anchor",
file: rel(repoRoot, filePath),
target: rawTarget,
resolved: rel(repoRoot, anchorFile),
anchor: target.anchor,
message: `missing anchor #${target.anchor} in ${rel(repoRoot, anchorFile)}`,
});
}
}
}
}
return errors;
}
function checkCommands({ repoRoot, docFiles, contentByFile, scriptsByDir }) {
const errors = [];
for (const filePath of docFiles) {
const markdown =
contentByFile.get(filePath) ?? fs.readFileSync(filePath, "utf8");
for (const match of markdown.matchAll(BUN_RUN_RE)) {
const cdCwd = match[1];
const cwdFlag = match[2];
const scriptName = match[3];
if (scriptName.startsWith("-")) {
continue;
}
const commandCwd = normalizeCwd(repoRoot, filePath, cwdFlag ?? cdCwd);
const scriptDir = findNearestScriptDir(
commandCwd,
scriptsByDir,
repoRoot,
);
const scripts = scriptsByDir.get(scriptDir) ?? {};
if (!Object.hasOwn(scripts, scriptName)) {
errors.push({
type: "missing-script",
file: rel(repoRoot, filePath),
line: lineForOffset(markdown, match.index),
script: scriptName,
cwd: rel(repoRoot, scriptDir) || ".",
message: `missing bun script "${scriptName}" in ${rel(repoRoot, scriptDir) || "."}`,
});
}
}
}
return errors;
}
export function checkDocs(options = {}) {
const repoRoot = path.resolve(options.repoRoot ?? defaultRepoRoot);
const docFiles =
options.docFiles?.map((filePath) => path.resolve(repoRoot, filePath)) ??
collectDocs(repoRoot, options.scope ?? "all");
const contentByFile = new Map();
for (const filePath of docFiles) {
contentByFile.set(filePath, fs.readFileSync(filePath, "utf8"));
}
const scriptsByDir = collectPackageScripts(repoRoot);
const errors = [
...checkLinks({ repoRoot, docFiles, contentByFile }),
...checkCommands({ repoRoot, docFiles, contentByFile, scriptsByDir }),
];
if (docFiles.length === 0) {
errors.push({
type: "no-docs",
file: "",
message: `no markdown files found for docs scope "${options.scope ?? "all"}"`,
});
}
return {
ok: errors.length === 0,
checkedFiles: docFiles.map((filePath) => rel(repoRoot, filePath)),
errorCount: errors.length,
errors,
};
}
function printHuman(result) {
if (result.ok) {
console.log(
`[docs-gate] PASS checked ${result.checkedFiles.length} markdown file(s)`,
);
return;
}
console.error(
`[docs-gate] FAIL ${result.errorCount} issue(s) across ${result.checkedFiles.length} markdown file(s)`,
);
for (const error of result.errors) {
const where = error.line ? `${error.file}:${error.line}` : error.file;
console.error(`- ${where} ${error.message}`);
}
}
function parseArgs(argv) {
const scope =
argv.find((arg) => arg.startsWith("--scope="))?.slice("--scope=".length) ??
(argv.includes("--launchdocs-only") ? "launchdocs" : undefined);
return {
json: argv.includes("--json"),
scope,
repoRoot:
argv
.find((arg) => arg.startsWith("--repo-root="))
?.slice("--repo-root=".length) ?? process.env.DOCS_GATE_REPO_ROOT,
};
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const args = parseArgs(process.argv.slice(2));
const result = checkDocs({ repoRoot: args.repoRoot, scope: args.scope });
if (args.json) {
console.log(JSON.stringify(result, null, 2));
} else {
printHuman(result);
}
process.exit(result.ok ? 0 : 1);
}