-
Notifications
You must be signed in to change notification settings - Fork 5
ci: statically check doc code blocks on every PR #70
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| name: docs-check | ||
|
|
||
| on: | ||
| pull_request: | ||
| push: | ||
| branches: [main] | ||
|
|
||
| jobs: | ||
| code-blocks: | ||
| name: Check doc code blocks | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
| - uses: actions/setup-node@v4 | ||
| with: | ||
| node-version: 22 | ||
| cache: yarn | ||
| - run: yarn install --frozen-lockfile | ||
| - run: yarn check-docs | ||
|
|
||
| build: | ||
| name: Build site | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
| - uses: actions/setup-node@v4 | ||
| with: | ||
| node-version: 22 | ||
| cache: yarn | ||
| - run: yarn install --frozen-lockfile | ||
| - run: yarn build | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,7 @@ | |
| "docusaurus": "docusaurus", | ||
| "start": "docusaurus start", | ||
| "build": "docusaurus build && node scripts/fix-markdown-links.js", | ||
| "check-docs": "node scripts/check-docs-code.mjs", | ||
| "swizzle": "docusaurus swizzle", | ||
| "deploy": "docusaurus deploy", | ||
| "clear": "docusaurus clear", | ||
|
|
@@ -30,8 +31,16 @@ | |
| "@docusaurus/faster": "3.10.1", | ||
| "@docusaurus/module-type-aliases": "3.10.1", | ||
| "@tailwindcss/postcss": "^4.3.1", | ||
| "@types/node": "^26.1.0", | ||
| "abstractionkit": "0.4.0", | ||
| "dotenv": "^17.4.2", | ||
| "ethers": "^6.17.0", | ||
| "safe-recovery-service-sdk": "^0.0.4", | ||
| "siwe": "^3.0.0", | ||
| "tailwindcss": "^4.3.1", | ||
| "typescript": "^6.0.3" | ||
| "typescript": "^6.0.3", | ||
| "viem": "^2.54.2", | ||
| "wagmi": "^3.6.21" | ||
|
Comment on lines
+34
to
+43
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🌐 Web query:
💡 Result: Yes, major version mismatches between Citations:
🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '\n== package.json ==\n'
cat -n package.json | sed -n '1,140p'
printf '\n== docs-check workflow ==\n'
cat -n .github/workflows/docs-check.yml | sed -n '1,120p'
printf '\n== search for node/version references ==\n'
rg -n --hidden --glob '!**/node_modules/**' 'setup-node|node-version|`@types/node`|engines' .github package.jsonRepository: candidelabs/developer-docs Length of output: 3760 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '\n== tsconfig files ==\n'
fd -a 'tsconfig*.json' .
printf '\n== docs-check script ==\n'
cat -n scripts/check-docs-code.mjs | sed -n '1,260p'
printf '\n== tsconfig / type references ==\n'
rg -n --hidden --glob '!**/node_modules/**' '"types"|`@types/node`|node:' tsconfig*.json . scripts package.json .github/workflowsRepository: candidelabs/developer-docs Length of output: 12515 Align 🤖 Prompt for AI Agents |
||
| }, | ||
| "browserslist": { | ||
| "production": [ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,199 @@ | ||
| #!/usr/bin/env node | ||
| /** | ||
| * Static checker for code blocks in the docs. | ||
| * | ||
| * - ```json blocks must parse with JSON.parse | ||
| * - ```ts / ```typescript / ```tsx blocks must typecheck with tsc --noEmit | ||
| * against the pinned abstractionkit version in devDependencies | ||
| * | ||
| * Nothing is executed: no network, no keys, no chain. This catches references | ||
| * to renamed SDK methods, wrong field names, syntax errors, and invalid JSON. | ||
| * | ||
| * Escape hatches: | ||
| * - Put `<!-- docs-check: skip -->` on its own line directly above a fence to | ||
| * exempt that one block (use for intentionally elided snippets). | ||
| * - Pre-existing failures live in scripts/docs-check-baseline.json, keyed by a | ||
| * hash of the block's content. CI fails only on NEW failures. Fixing a block | ||
| * changes its hash, so fixed blocks leave the baseline automatically; run | ||
| * with --update-baseline to rewrite the file (also prunes stale entries). | ||
| */ | ||
|
|
||
| import { createHash } from "node:crypto"; | ||
| import { execFileSync } from "node:child_process"; | ||
| import fs from "node:fs"; | ||
| import path from "node:path"; | ||
| import { fileURLToPath } from "node:url"; | ||
|
|
||
| const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); | ||
| const docsDir = path.join(repoRoot, "docs"); | ||
| const baselinePath = path.join(repoRoot, "scripts", "docs-check-baseline.json"); | ||
| const tmpDir = path.join(repoRoot, "node_modules", ".cache", "docs-check"); | ||
| const updateBaseline = process.argv.includes("--update-baseline"); | ||
|
|
||
| const TS_LANGS = new Set(["ts", "typescript", "tsx"]); | ||
| const SKIP_MARKER = /<!--\s*docs-check:\s*skip\s*-->/; | ||
|
|
||
| // ---------------------------------------------------------------- extraction | ||
|
|
||
| function* walk(dir) { | ||
| for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { | ||
| const full = path.join(dir, entry.name); | ||
| if (entry.isDirectory()) yield* walk(full); | ||
| else if (/\.mdx?$/.test(entry.name)) yield full; | ||
| } | ||
| } | ||
|
|
||
| function extractBlocks(filePath) { | ||
| const lines = fs.readFileSync(filePath, "utf8").split("\n"); | ||
| const blocks = []; | ||
| let open = null; // { lang, indent, startLine, content: [] } | ||
| for (let i = 0; i < lines.length; i++) { | ||
| const line = lines[i]; | ||
| if (open) { | ||
| if (line.slice(open.indent.length).trimEnd() === "```" || line.trim() === "```") { | ||
| blocks.push({ ...open, content: open.content.join("\n") }); | ||
| open = null; | ||
| } else { | ||
| open.content.push(line.startsWith(open.indent) ? line.slice(open.indent.length) : line); | ||
| } | ||
| continue; | ||
| } | ||
| const fence = line.match(/^(\s*)```(\w+)/); | ||
| if (!fence) continue; | ||
| const skip = i > 0 && SKIP_MARKER.test(lines[i - 1]); | ||
| const lang = fence[2].toLowerCase(); | ||
| if (!skip && (lang === "json" || TS_LANGS.has(lang))) { | ||
| open = { lang, indent: fence[1], startLine: i + 2, content: [] }; | ||
| } else { | ||
| // still consume the fence so nested content is not re-scanned | ||
| open = { lang: null, indent: fence[1], startLine: i + 2, content: [] }; | ||
| } | ||
| } | ||
| return blocks.filter((b) => b.lang !== null); | ||
| } | ||
|
|
||
| function blockHash(lang, content) { | ||
| return createHash("sha256").update(`${lang}\n${content}`).digest("hex").slice(0, 12); | ||
| } | ||
|
|
||
| // ------------------------------------------------------------------- checks | ||
|
|
||
| const failures = []; // { file, line, hash, lang, message } | ||
| const tsBlocks = []; // { scratchFile, docFile, startLine, hash } | ||
|
|
||
| fs.rmSync(tmpDir, { recursive: true, force: true }); | ||
| fs.mkdirSync(tmpDir, { recursive: true }); | ||
|
|
||
| let total = 0; | ||
| for (const file of walk(docsDir)) { | ||
| const rel = path.relative(repoRoot, file); | ||
| for (const block of extractBlocks(file)) { | ||
| total++; | ||
| const hash = blockHash(block.lang, block.content); | ||
| if (block.lang === "json") { | ||
| try { | ||
| JSON.parse(block.content); | ||
| } catch (err) { | ||
| failures.push({ file: rel, line: block.startLine, hash, lang: "json", message: err.message }); | ||
| } | ||
| } else { | ||
| const ext = block.lang === "tsx" ? "tsx" : "ts"; | ||
| const scratchFile = path.join(tmpDir, `block-${tsBlocks.length}.${ext}`); | ||
| // `export {}` makes the block a module: imports and top-level await work, | ||
| // and declarations cannot collide across blocks. | ||
| fs.writeFileSync(scratchFile, `${block.content}\nexport {};\n`); | ||
| tsBlocks.push({ scratchFile, docFile: rel, startLine: block.startLine, hash }); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| function runTsc(files, configName) { | ||
| const tsconfig = { | ||
| compilerOptions: { | ||
| target: "ES2022", | ||
| module: "ESNext", | ||
| moduleResolution: "Bundler", | ||
| jsx: "react-jsx", | ||
| lib: ["ES2022", "DOM"], | ||
| types: ["node"], | ||
| strict: false, | ||
| noEmit: true, | ||
| skipLibCheck: true, | ||
| esModuleInterop: true, | ||
| }, | ||
| files, | ||
| }; | ||
| const configPath = path.join(tmpDir, configName); | ||
| fs.writeFileSync(configPath, JSON.stringify(tsconfig, null, 2)); | ||
| let output = ""; | ||
| try { | ||
| execFileSync("npx", ["tsc", "-p", configPath, "--pretty", "false"], { | ||
| cwd: repoRoot, | ||
| encoding: "utf8", | ||
| maxBuffer: 64 * 1024 * 1024, | ||
| }); | ||
| } catch (err) { | ||
| output = `${err.stdout ?? ""}${err.stderr ?? ""}`; | ||
| } | ||
| const errors = new Map(); // scratchFile -> [{ line, message }] | ||
| for (const line of output.split("\n")) { | ||
| const m = line.match(/^(.*?)\((\d+),(\d+)\): (error TS\d+: .*)$/); | ||
| if (!m) continue; | ||
| const file = path.resolve(repoRoot, m[1]); | ||
| const entry = errors.get(file) ?? []; | ||
| entry.push({ line: Number(m[2]), message: m[4] }); | ||
| errors.set(file, entry); | ||
| } | ||
| return errors; | ||
| } | ||
|
Comment on lines
+110
to
+148
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '\n== scripts/check-docs-code.mjs (relevant section) ==\n'
nl -ba scripts/check-docs-code.mjs | sed -n '1,220p'
printf '\n== package.json / lockfile references to typescript, npx, and node typings ==\n'
for f in package.json npm-shrinkwrap.json package-lock.json pnpm-lock.yaml yarn.lock; do
if [ -f "$f" ]; then
echo "--- $f ---"
rg -n '"typescript"|`@types/node`|npx|tsc' "$f" || true
fi
doneRepository: candidelabs/developer-docs Length of output: 258 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '\n== scripts/check-docs-code.mjs (relevant section) ==\n'
cat -n scripts/check-docs-code.mjs | sed -n '1,220p'
printf '\n== package.json ==\n'
cat -n package.json | sed -n '1,260p'
printf '\n== lockfile references to typescript / `@types/node` ==\n'
for f in package-lock.json npm-shrinkwrap.json pnpm-lock.yaml yarn.lock; do
if [ -f "$f" ]; then
echo "--- $f ---"
rg -n '"typescript"|`@types/node`|typescript@|`@types/node`@' "$f" || true
fi
doneRepository: candidelabs/developer-docs Length of output: 13102 Fail closed when 🤖 Prompt for AI Agents |
||
|
|
||
| if (tsBlocks.length > 0) { | ||
| // Two passes: when any file in the program has syntax errors, tsc skips | ||
| // semantic checking for the whole program. Pass 1 catches syntax failures; | ||
| // pass 2 re-checks the syntactically clean blocks for semantic errors. | ||
| const pass1 = runTsc(tsBlocks.map((b) => b.scratchFile), "tsconfig.json"); | ||
| const cleanFiles = tsBlocks.map((b) => b.scratchFile).filter((f) => !pass1.has(f)); | ||
| const pass2 = cleanFiles.length > 0 ? runTsc(cleanFiles, "tsconfig-pass2.json") : new Map(); | ||
|
|
||
| for (const block of tsBlocks) { | ||
| const errors = pass1.get(block.scratchFile) ?? pass2.get(block.scratchFile); | ||
| if (!errors) continue; | ||
| failures.push({ | ||
| file: block.docFile, | ||
| line: block.startLine + errors[0].line - 1, | ||
| hash: block.hash, | ||
| lang: "ts", | ||
| message: errors | ||
| .map((e) => `L${block.startLine + e.line - 1}: ${e.message}`) | ||
| .join("\n "), | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| // ----------------------------------------------------------------- baseline | ||
|
|
||
| const baseline = fs.existsSync(baselinePath) ? JSON.parse(fs.readFileSync(baselinePath, "utf8")) : {}; | ||
| const failingHashes = new Set(failures.map((f) => f.hash)); | ||
| const newFailures = failures.filter((f) => !(f.hash in baseline)); | ||
| const staleEntries = Object.keys(baseline).filter((h) => !failingHashes.has(h)); | ||
|
|
||
| if (updateBaseline) { | ||
| const next = {}; | ||
| for (const f of failures) next[f.hash] = `${f.file}:${f.line} (${f.lang})`; | ||
| fs.writeFileSync(baselinePath, JSON.stringify(next, null, 2) + "\n"); | ||
| console.log(`Baseline rewritten: ${failures.length} known failure(s).`); | ||
| process.exit(0); | ||
| } | ||
|
|
||
| console.log(`Checked ${total} code block(s): ${total - failures.length} pass, ${failures.length - newFailures.length} baselined, ${newFailures.length} new failure(s).`); | ||
| if (staleEntries.length > 0) { | ||
| console.log(`${staleEntries.length} baseline entr(ies) no longer fail; run --update-baseline to prune.`); | ||
| } | ||
|
|
||
| if (newFailures.length > 0) { | ||
| console.error("\nNew failures (fix the block, or add `<!-- docs-check: skip -->` above the fence for intentional elision):\n"); | ||
| for (const f of newFailures) { | ||
| console.error(` ${f.file}:${f.line} [${f.lang}] (${f.hash})\n ${f.message}\n`); | ||
| } | ||
| process.exit(1); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win
Set
persist-credentials: falseon checkout steps.Both
actions/checkout@v4steps default to persisting theGITHUB_TOKENcredential in the workspace, which is unnecessary here since neither job pushes or authenticates further. Flagged by zizmor as credential persistence (artipacked).🔒 Proposed fix
Also applies to: 25-25
🧰 Tools
🪛 zizmor (1.26.1)
[warning] 13-13: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false
(artipacked)
🤖 Prompt for AI Agents
Source: Linters/SAST tools