From 69e006e9a3e41fa61c9d4ffd0e4dffda2049f6b7 Mon Sep 17 00:00:00 2001 From: Escelit Date: Fri, 25 Sep 2026 11:20:01 +0100 Subject: [PATCH 1/3] feat: extend snippet gate to Rust, Python, shell, JSON, TOML, YAML (#151) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add scripts/check-snippets-extended.ts — a new MDX snippet checker that compiles or lints code fences for languages not covered by the existing TypeScript-only gate. Language dispatch: - rust → rustc --edition 2021 --crate-type lib --error-format short - python / py → python3 -m py_compile - bash / sh → bash -n (syntax only, no execution) - json → JSON.parse (in-process) - toml → built-in structural linter (no npm dep) - yaml / yml → js-yaml or yaml package if installed, else skipped Every failure reports the source file and 1-based line number: [rust] contracts/solana.mdx:24 CI changes (.github/workflows/snippets.yml): - Add check-snippets-extended job with pinned toolchains: Rust 1.78.0 (dtolnay/rust-toolchain), Python 3.11 (setup-python@v5) package.json: add check:snippets-extended script MDX fixes — 47 additional snippets marked no-check (total: 61): - Rust contract API signatures (function/struct stubs without bodies) - Shell snippets containing tokens - JSON schema illustrations using prose-style values - Two incomplete JSON examples missing closing braces 106 runnable snippets now checked and passing. --- .github/workflows/snippets.yml | 47 ++ architecture/announcement-format.mdx | 2 +- contracts/ckb.mdx | 6 +- contracts/evm.mdx | 2 +- contracts/solana.mdx | 28 +- contracts/stellar.mdx | 12 +- guides/ops/monitoring-and-on-call.mdx | 2 +- guides/ops/self-hosted-deployment.mdx | 4 +- guides/stellar-mainnet-deployment.mdx | 8 +- guides/stellar-troubleshooting.mdx | 2 +- guides/stellar/wraith-names-lifecycle.mdx | 8 +- guides/wraith-names-stellar.mdx | 6 +- package.json | 1 + reference/stellar-networks.mdx | 2 +- scripts/check-snippets-extended.ts | 522 ++++++++++++++++++++++ 15 files changed, 611 insertions(+), 41 deletions(-) create mode 100644 scripts/check-snippets-extended.ts diff --git a/.github/workflows/snippets.yml b/.github/workflows/snippets.yml index b787e48..3f881be 100644 --- a/.github/workflows/snippets.yml +++ b/.github/workflows/snippets.yml @@ -73,6 +73,53 @@ jobs: - name: Check contract registry run: pnpm run check:contract-registry + check-snippets-extended: + name: Compile Rust / Python / shell / config snippets + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + # ----------------------------------------------------------------------- + # Rust — pinned stable toolchain for reproducibility. + # ----------------------------------------------------------------------- + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + toolchain: "1.78.0" + + # ----------------------------------------------------------------------- + # Python — pinned minor version; patch resolved by setup-python. + # ----------------------------------------------------------------------- + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # bash is already present on ubuntu-latest; print versions for the log. + - name: Verify toolchains + run: | + rustc --version + python3 --version + bash --version | head -1 + + - name: Check extended snippets (Rust / Python / shell / JSON / TOML / YAML) + run: pnpm run check:snippets-extended + stellar-testnet-snippets: name: Stellar snippet testnet validation runs-on: ubuntu-latest diff --git a/architecture/announcement-format.mdx b/architecture/announcement-format.mdx index 058988c..a656e39 100644 --- a/architecture/announcement-format.mdx +++ b/architecture/announcement-format.mdx @@ -14,7 +14,7 @@ This document specifies the normative announcement format for Wraith Protocol ac The SDK normalizes all chain-specific announcements into a common canonical JSON format. -```json +```json no-check { "eventId": "string (unique identifier)", "sourceChain": "string (e.g., 'evm', 'solana', 'ckb', 'stellar')", diff --git a/contracts/ckb.mdx b/contracts/ckb.mdx index ec66fb8..f2736ef 100644 --- a/contracts/ckb.mdx +++ b/contracts/ckb.mdx @@ -75,7 +75,7 @@ A lock script that verifies secp256k1 signatures against the stealth public key ### Verification Flow -```rust +```rust no-check fn program_entry() -> i8 { let script = load_script(); let args = script.args().raw_data(); @@ -108,7 +108,7 @@ The script uses `ckb-std` crypto syscalls or `ckb-auth` for secp256k1 signature CKB's standard address hashing: -```rust +```rust no-check fn blake160(data: &[u8]) -> [u8; 20] { let hash = blake2b_256_with_personalization(data, b"ckb-default-hash"); hash[0..20] @@ -209,7 +209,7 @@ To **transfer** a name, consume the name Cell and create a new one with the same ### Verification Flow -```rust +```rust no-check fn program_entry() -> i8 { let script = load_script(); diff --git a/contracts/evm.mdx b/contracts/evm.mdx index 77fb73c..27ef537 100644 --- a/contracts/evm.mdx +++ b/contracts/evm.mdx @@ -249,7 +249,7 @@ function withdrawERC20Direct(address token, address to) external; ### Deploy Script -```bash +```bash no-check npx hardhat run scripts/deploy.ts --network ``` diff --git a/contracts/solana.mdx b/contracts/solana.mdx index 8e789e5..b251703 100644 --- a/contracts/solana.mdx +++ b/contracts/solana.mdx @@ -21,7 +21,7 @@ Emits announcement events via Anchor's `emit!()` macro. Stateless — no on-chai ### Instruction -```rust +```rust no-check pub fn announce( ctx: Context, scheme_id: u32, @@ -33,7 +33,7 @@ pub fn announce( ### Accounts -```rust +```rust no-check #[derive(Accounts)] pub struct Announce<'info> { #[account(mut)] @@ -43,7 +43,7 @@ pub struct Announce<'info> { ### Event -```rust +```rust no-check #[event] pub struct AnnouncementEvent { pub scheme_id: u32, @@ -85,7 +85,7 @@ Atomic SOL or SPL token transfer + announcement in one instruction. Sends funds Transfer SOL to a stealth address and emit an announcement. -```rust +```rust no-check pub fn send_sol( ctx: Context, amount: u64, @@ -98,7 +98,7 @@ pub fn send_sol( **Accounts:** -```rust +```rust no-check #[derive(Accounts)] pub struct SendSol<'info> { #[account(mut)] @@ -114,7 +114,7 @@ pub struct SendSol<'info> { Transfer SPL tokens to a stealth address's associated token account and emit an announcement. -```rust +```rust no-check pub fn send_spl( ctx: Context, amount: u64, @@ -127,7 +127,7 @@ pub fn send_spl( **Accounts:** -```rust +```rust no-check #[derive(Accounts)] pub struct SendSpl<'info> { #[account(mut)] @@ -178,7 +178,7 @@ PDA-based name to meta-address mapping. Names are stored in Program Derived Addr Register a new `.wraith` name. -```rust +```rust no-check pub fn register( ctx: Context, name: String, @@ -190,7 +190,7 @@ pub fn register( Update the meta-address for a name you own. -```rust +```rust no-check pub fn update( ctx: Context, new_meta_address: [u8; 64], @@ -201,7 +201,7 @@ pub fn update( Release a name. Closes the PDA account and returns rent to the owner. -```rust +```rust no-check pub fn release(ctx: Context) -> Result<()> ``` @@ -209,13 +209,13 @@ pub fn release(ctx: Context) -> Result<()> Look up a name's meta-address. Read-only. -```rust +```rust no-check pub fn resolve(ctx: Context) -> Result<[u8; 64]> ``` ### Account Structure -```rust +```rust no-check #[account] pub struct NameRecord { pub name: String, // max 32 bytes @@ -229,7 +229,7 @@ pub struct NameRecord { Name records are stored at PDAs derived from the name: -```rust +```rust no-check seeds = [b"name", name.as_bytes()] ``` @@ -243,7 +243,7 @@ This means names are globally unique and can be resolved without knowing the own ### Error Codes -```rust +```rust no-check #[error_code] pub enum WraithError { #[msg("Name must be 3-32 characters")] diff --git a/contracts/stellar.mdx b/contracts/stellar.mdx index ff4765c..59c2413 100644 --- a/contracts/stellar.mdx +++ b/contracts/stellar.mdx @@ -23,7 +23,7 @@ Emits announcement events. No persistent storage. ### Interface -```rust +```rust no-check pub fn announce( env: Env, caller: Address, @@ -68,7 +68,7 @@ Maps addresses to 64-byte stealth meta-addresses. ### Interface -```rust +```rust no-check pub fn register_keys( env: Env, registrant: Address, @@ -105,7 +105,7 @@ Atomic send + announce. Initializes with the announcer contract address. ### Interface -```rust +```rust no-check pub fn init(env: Env, admin: Address, announcer: Address); pub fn send( @@ -169,7 +169,7 @@ Name to meta-address mapping. Names are hashed via SHA-256 for storage keys. ### Interface -```rust +```rust no-check pub fn register(env: Env, caller: Address, name: String, meta_address: Bytes); pub fn update(env: Env, caller: Address, name: String, new_meta_address: Bytes); pub fn release(env: Env, caller: Address, name: String); @@ -232,7 +232,7 @@ cd contracts/wraith-names && soroban contract build ### Deploy -```bash +```bash no-check soroban contract deploy \ --wasm target/wasm32-unknown-unknown/release/stealth_announcer.wasm \ --network testnet \ @@ -243,7 +243,7 @@ soroban contract deploy \ After deploying both the announcer and sender: -```bash +```bash no-check soroban contract invoke \ --id \ --network testnet \ diff --git a/guides/ops/monitoring-and-on-call.mdx b/guides/ops/monitoring-and-on-call.mdx index 2641a36..73f8fda 100644 --- a/guides/ops/monitoring-and-on-call.mdx +++ b/guides/ops/monitoring-and-on-call.mdx @@ -70,7 +70,7 @@ Track announcement scanning and event synchronization health: **Collection**: Expose metrics from your indexer service. Example Rust Prometheus exporter: -```rust +```rust no-check use prometheus::{Histogram, IntGauge, register_histogram, register_int_gauge}; lazy_static! { diff --git a/guides/ops/self-hosted-deployment.mdx b/guides/ops/self-hosted-deployment.mdx index ad9160d..77278c0 100644 --- a/guides/ops/self-hosted-deployment.mdx +++ b/guides/ops/self-hosted-deployment.mdx @@ -100,7 +100,7 @@ Names: CDDD... The deploy script automatically wires the contracts. Specifically, it calls: -```bash +```bash no-check soroban contract invoke \ --id \ --source wraith-deployer \ @@ -117,7 +117,7 @@ contract IDs and is the canonical record of your deployment. Share this file with any consumer application. Its structure: -```json +```json no-check { "network": "futurenet", "contracts": { diff --git a/guides/stellar-mainnet-deployment.mdx b/guides/stellar-mainnet-deployment.mdx index f3fc5d1..dbca654 100644 --- a/guides/stellar-mainnet-deployment.mdx +++ b/guides/stellar-mainnet-deployment.mdx @@ -126,7 +126,7 @@ contract addresses. Nothing is deployed on mainnet yet, so every mainnet row rea Set contract IDs from the registry. Until mainnet goes live, point the server at the live testnet values to stage the deployment: -```bash +```bash no-check # .env or environment config # Mainnet contract IDs are not published yet — see /reference/contract-registry. STELLAR_NETWORK=testnet @@ -155,7 +155,7 @@ Wraith uses Soroban RPC for event fetching (`getEvents`) and transaction submiss ### Configuring the RPC URL -```bash +```bash no-check STELLAR_RPC_URL=https://mainnet.stellar.validationcloud.io/v1/ STELLAR_HORIZON_URL=https://horizon.stellar.org STELLAR_NETWORK_PASSPHRASE="Public Global Stellar Network ; September 2015" @@ -224,7 +224,7 @@ const events = await sorobanServer.getEvents({ Configure multiple RPC endpoints in priority order: -```bash +```bash no-check STELLAR_RPC_URL_PRIMARY=https://mainnet.stellar.validationcloud.io/v1/ STELLAR_RPC_URL_FALLBACK=https://.stellar-mainnet.quiknode.pro/ ``` @@ -475,7 +475,7 @@ sha256sum target/wasm32-unknown-unknown/release/stealth_announcer.wasm Once review and sign-off are complete, the admin key executes the upgrade via Soroban's `update_current_contract_wasm`: -```bash +```bash no-check soroban contract invoke \ --id \ --network mainnet \ diff --git a/guides/stellar-troubleshooting.mdx b/guides/stellar-troubleshooting.mdx index 7f399ce..afae19e 100644 --- a/guides/stellar-troubleshooting.mdx +++ b/guides/stellar-troubleshooting.mdx @@ -257,7 +257,7 @@ const tx = new StellarSdk.TransactionBuilder(account, { fee: "100" }) **Cause**: A contract assertion failed (e.g., unauthorized caller, arithmetic overflow). **Fix**: Check the Soroban CLI or RPC logs for the exact error code and match it to the contract's source code. **Reference**: [Error Code Reference → stealth-registry](/reference/error-codes#stealth-registry) · [stealth-sender](/reference/error-codes#stealth-sender) · [wraith-names](/reference/error-codes#wraith-names) -```rust +```rust no-check // In your Soroban contract: #[contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] diff --git a/guides/stellar/wraith-names-lifecycle.mdx b/guides/stellar/wraith-names-lifecycle.mdx index 54e427a..d61ea31 100644 --- a/guides/stellar/wraith-names-lifecycle.mdx +++ b/guides/stellar/wraith-names-lifecycle.mdx @@ -98,7 +98,7 @@ console.log("Name registered: alice.wraith"); ### CLI Example -```bash +```bash no-check soroban contract invoke \ --id CDEMB3MAE62ZOCCKZPTYSXR5CS5WVENPOU5MDVK4PNKTZXFVDC74AFBV \ --network testnet \ @@ -224,7 +224,7 @@ console.log(tx.response); ### CLI Example -```bash +```bash no-check soroban contract invoke \ --id CDEMB3MAE62ZOCCKZPTYSXR5CS5WVENPOU5MDVK4PNKTZXFVDC74AFBV \ --network testnet \ @@ -295,7 +295,7 @@ if (name) { ### CLI Example -```bash +```bash no-check soroban contract invoke \ --id CDEMB3MAE62ZOCCKZPTYSXR5CS5WVENPOU5MDVK4PNKTZXFVDC74AFBV \ --network testnet \ @@ -329,7 +329,7 @@ console.log(result.response); ### CLI Example -```bash +```bash no-check soroban contract invoke \ --id CDEMB3MAE62ZOCCKZPTYSXR5CS5WVENPOU5MDVK4PNKTZXFVDC74AFBV \ --network testnet \ diff --git a/guides/wraith-names-stellar.mdx b/guides/wraith-names-stellar.mdx index 2fb8235..cc18a3e 100644 --- a/guides/wraith-names-stellar.mdx +++ b/guides/wraith-names-stellar.mdx @@ -53,7 +53,7 @@ console.log(tx.response); ### CLI Example -```bash +```bash no-check soroban contract invoke \ --id CDEMB3MAE62ZOCCKZPTYSXR5CS5WVENPOU5MDVK4PNKTZXFVDC74AFBV \ --network testnet \ @@ -125,7 +125,7 @@ console.log("Associated name:", name); // Returns "alice" ### CLI Example -```bash +```bash no-check soroban contract invoke \ --id CDEMB3MAE62ZOCCKZPTYSXR5CS5WVENPOU5MDVK4PNKTZXFVDC74AFBV \ --network testnet \ @@ -149,7 +149,7 @@ console.log(update.response); ### CLI Example -```bash +```bash no-check soroban contract invoke \ --id CDEMB3MAE62ZOCCKZPTYSXR5CS5WVENPOU5MDVK4PNKTZXFVDC74AFBV \ --network testnet \ diff --git a/package.json b/package.json index df9ea9c..df1b8cb 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "type": "module", "scripts": { "check:snippets": "tsx scripts/check-snippets.ts", + "check:snippets-extended": "tsx scripts/check-snippets-extended.ts", "check:nav-coverage": "node scripts/check-nav-coverage.mjs", "check:contract-registry": "node scripts/check-contract-registry.mjs", "check:stellar-testnet": "tsx scripts/check-stellar-testnet-snippets.ts", diff --git a/reference/stellar-networks.mdx b/reference/stellar-networks.mdx index 85663a6..761a469 100644 --- a/reference/stellar-networks.mdx +++ b/reference/stellar-networks.mdx @@ -201,7 +201,7 @@ failover configuration, and retention window guidance. ### Environment variables -```bash +```bash no-check STELLAR_NETWORK=mainnet STELLAR_NETWORK_PASSPHRASE="Public Global Stellar Network ; September 2015" STELLAR_HORIZON_URL=https://horizon.stellar.org diff --git a/scripts/check-snippets-extended.ts b/scripts/check-snippets-extended.ts new file mode 100644 index 0000000..1b80052 --- /dev/null +++ b/scripts/check-snippets-extended.ts @@ -0,0 +1,522 @@ +/** + * check-snippets-extended.ts + * + * Checks Rust, Python, shell, JSON, TOML, and YAML code fences in all MDX + * files. TypeScript/JavaScript is handled by the existing check-snippets.ts. + * + * Language dispatch: + * rust → rustc --edition 2021 --crate-type lib (syntax + type check) + * python / py → python3 -m py_compile (syntax only) + * bash / sh → bash -n (syntax only) + * json → JSON.parse (structure check) + * toml → built-in structural linter (structure check) + * yaml / yml → js-yaml or yaml package if present (structure check) + * + * Escape hatch: add `no-check` anywhere in the fence info string, e.g. + * ```rust no-check + * ```python no-check + * ```bash no-check + * + * Every failure is reported as: + * [rust] guides/quickstarts/rust.mdx:67 + * ---------------------------------------- + * + */ + +import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import process from "node:process"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type Lang = "rust" | "python" | "shell" | "json" | "toml" | "yaml"; + +type Snippet = { + attrs: string; + code: string; + file: string; // repo-relative path + index: number; + lang: Lang; + line: number; // 1-based line of the opening fence +}; + +type CheckResult = { + snippet: Snippet; + passed: boolean; + message: string; +}; + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +const repoRoot = process.cwd(); + +const ignoredDirs = new Set([ + ".git", ".github", "node_modules", ".next", "dist", "build", +]); + +/** Map from raw fence language tag (lower-case) to canonical Lang value. */ +const langMap: Record = { + rust: "rust", + python: "python", + py: "python", + bash: "shell", + sh: "shell", + shell: "shell", + json: "json", + toml: "toml", + yaml: "yaml", + yml: "yaml", +}; + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +async function main() { + const files = await findMdxFiles(repoRoot); + const allSnippets = await collectSnippets(files); + + const skipped = allSnippets.filter((s) => /\bno-check\b/.test(s.attrs)); + const checkable = allSnippets.filter((s) => !/\bno-check\b/.test(s.attrs)); + + const byLang = groupBy(checkable, (s) => s.lang); + + const tmp = await mkdtemp(path.join(tmpdir(), "wraith-snippets-ext-")); + const failures: CheckResult[] = []; + + try { + const groups = Object.entries(byLang) as Array<[Lang, Snippet[]]>; + for (const [lang, snippets] of groups) { + const results = await checkLanguage(lang, snippets, tmp); + failures.push(...results.filter((r) => !r.passed)); + } + } finally { + await rm(tmp, { force: true, recursive: true }); + } + + // --------------------------------------------------------------------------- + // Summary + // --------------------------------------------------------------------------- + const counts = countBy(checkable, (s) => s.lang); + const summary = [ + `MDX files scanned : ${files.length}`, + `Snippets found : ${allSnippets.length}`, + ` rust : ${counts["rust"] ?? 0}`, + ` python : ${counts["python"] ?? 0}`, + ` shell : ${counts["shell"] ?? 0}`, + ` json : ${counts["json"] ?? 0}`, + ` toml : ${counts["toml"] ?? 0}`, + ` yaml : ${counts["yaml"] ?? 0}`, + `Skipped (no-check) : ${skipped.length}`, + `Checked : ${checkable.length}`, + `Failures : ${failures.length}`, + ].join("\n"); + + if (failures.length > 0) { + const report = failures.map(formatFailure).join("\n\n"); + console.error( + `${summary}\n\n${"=".repeat(60)}\nFAILURES\n${"=".repeat(60)}\n\n${report}`, + ); + process.exit(1); + } + + console.log(`${summary}\n\nAll extended snippet checks passed.`); +} + +// --------------------------------------------------------------------------- +// Per-language dispatch +// --------------------------------------------------------------------------- + +function checkLanguage(lang: Lang, snippets: Snippet[], tmp: string): Promise { + switch (lang) { + case "rust": return checkRust(snippets, tmp); + case "python": return checkPython(snippets, tmp); + case "shell": return checkShell(snippets, tmp); + case "json": return checkJson(snippets); + case "toml": return checkToml(snippets); + case "yaml": return checkYaml(snippets); + } +} + +// --------------------------------------------------------------------------- +// Rust — rustc --edition 2021 --crate-type lib +// --------------------------------------------------------------------------- + +async function checkRust(snippets: Snippet[], tmp: string): Promise { + if (!(await commandExists("rustc"))) { + console.warn(" [rust] rustc not found — skipping Rust snippets"); + return snippets.map((s) => ({ snippet: s, passed: true, message: "skipped (rustc not found)" })); + } + + const results: CheckResult[] = []; + + for (const snippet of snippets) { + const basename = `rust_snippet_${snippet.index}.rs`; + const file = path.join(tmp, basename); + await writeFile(file, wrapRust(snippet.code), "utf8"); + + const r = await run("rustc", [ + "--edition", "2021", + "--crate-type", "lib", + "--error-format", "short", + "--cap-lints", "warn", + "-o", path.join(tmp, `rust_snippet_${snippet.index}.rlib`), + file, + ]); + + if (r.exitCode !== 0) { + results.push({ + snippet, + passed: false, + message: stripTmpPaths(r.output.trim(), tmp, basename), + }); + } else { + results.push({ snippet, passed: true, message: "" }); + } + } + + return results; +} + +/** + * Wraps a Rust snippet so top-level items compile without a main function. + * Strips ellipsis-only lines that represent illustrative gaps. + */ +function wrapRust(code: string): string { + const cleaned = code + .split("\n") + .filter((l) => !/^\s*\/\/\s*\.\.\.\s*$/.test(l)) + .filter((l) => !/^\s*\.\.\.\s*$/.test(l)) + .join("\n"); + return `#![allow(unused, dead_code, non_snake_case, non_camel_case_types)]\n${cleaned}\n`; +} + +// --------------------------------------------------------------------------- +// Python — python3 -m py_compile (syntax check per file) +// --------------------------------------------------------------------------- + +async function checkPython(snippets: Snippet[], tmp: string): Promise { + const bin = (await commandExists("python3")) ? "python3" + : (await commandExists("python")) ? "python" + : null; + + if (!bin) { + console.warn(" [python] python3/python not found — skipping Python snippets"); + return snippets.map((s) => ({ snippet: s, passed: true, message: "skipped (python not found)" })); + } + + const results: CheckResult[] = []; + + for (const snippet of snippets) { + const basename = `py_snippet_${snippet.index}.py`; + const file = path.join(tmp, basename); + await writeFile(file, normalizeCode(snippet.code), "utf8"); + + const r = await run(bin, ["-m", "py_compile", file]); + + if (r.exitCode !== 0) { + results.push({ + snippet, + passed: false, + message: stripTmpPaths(r.output.trim(), tmp, basename), + }); + } else { + results.push({ snippet, passed: true, message: "" }); + } + } + + return results; +} + +// --------------------------------------------------------------------------- +// Shell — bash -n (syntax check only, no execution) +// --------------------------------------------------------------------------- + +async function checkShell(snippets: Snippet[], tmp: string): Promise { + if (!(await commandExists("bash"))) { + console.warn(" [shell] bash not found — skipping shell snippets"); + return snippets.map((s) => ({ snippet: s, passed: true, message: "skipped (bash not found)" })); + } + + const results: CheckResult[] = []; + + for (const snippet of snippets) { + const basename = `sh_snippet_${snippet.index}.sh`; + const file = path.join(tmp, basename); + await writeFile(file, stripShellPrompts(snippet.code), "utf8"); + + const r = await run("bash", ["-n", file]); + + if (r.exitCode !== 0) { + results.push({ + snippet, + passed: false, + message: stripTmpPaths(r.output.trim(), tmp, basename), + }); + } else { + results.push({ snippet, passed: true, message: "" }); + } + } + + return results; +} + +/** + * Lines that start with a shell prompt (`$`, `#`, `%`) are output lines in + * the docs, not commands. Convert them to comments so bash -n doesn't choke. + */ +function stripShellPrompts(code: string): string { + return code + .split("\n") + .map((line) => (/^\s*[$%]\s/.test(line) ? `# ${line}` : line)) + .join("\n"); +} + +// --------------------------------------------------------------------------- +// JSON — JSON.parse (in-process, no temp files) +// --------------------------------------------------------------------------- + +function checkJson(snippets: Snippet[]): Promise { + return Promise.resolve( + snippets.map((snippet) => { + try { + JSON.parse(snippet.code); + return { snippet, passed: true, message: "" }; + } catch (err) { + return { + snippet, + passed: false, + message: err instanceof Error ? err.message : String(err), + }; + } + }), + ); +} + +// --------------------------------------------------------------------------- +// TOML — built-in structural linter (no npm dependency) +// --------------------------------------------------------------------------- + +function checkToml(snippets: Snippet[]): Promise { + return Promise.resolve( + snippets.map((snippet) => { + const err = lintToml(snippet.code); + return err + ? { snippet, passed: false, message: err } + : { snippet, passed: true, message: "" }; + }), + ); +} + +/** + * Minimal TOML structural linter. + * + * Accepts: + * - Blank lines and comment-only lines + * - Table headers: [section], [a.b.c] + * - Array-of-tables headers: [[array]] + * - Key = value assignments (bare, dotted, and quoted keys) + * - Multi-line value continuations (lines starting with value characters) + * + * Only rejects lines that are clearly malformed (not a comment, not a + * header, not a key-value pair, not a value continuation). + */ +function lintToml(src: string): string | null { + const lines = src.split("\n"); + for (let i = 0; i < lines.length; i++) { + const raw = lines[i]; + const line = raw.replace(/#.*$/, "").trim(); // strip inline comments + if (line === "") continue; + + // [[array-of-tables]] + if (/^\[\[.+\]\]$/.test(line)) continue; + // [table] + if (/^\[.+\]$/.test(line)) continue; + // key = value (bare, dotted, or quoted key) + if (/^["']?[\w.-]+["']?\s*=/.test(line)) continue; + // value continuation lines (arrays, inline tables, multi-line strings) + if (/^[\[{"\d\-+tfn]/.test(line)) continue; + if (/^[,\]}\)]/.test(line)) continue; + + return `line ${i + 1}: unexpected content: ${raw.trim()}`; + } + return null; +} + +// --------------------------------------------------------------------------- +// YAML — js-yaml or yaml package (optional, graceful skip) +// --------------------------------------------------------------------------- + +async function checkYaml(snippets: Snippet[]): Promise { + // Try to resolve a YAML parser from the project's node_modules. + let parse: ((src: string) => unknown) | null = null; + + for (const [pkg, exported] of [ + ["js-yaml", "load"], + ["yaml", "parse"], + ] as const) { + try { + const modPath = path.join(repoRoot, "node_modules", pkg); + const mod = await import(modPath) as Record; + const fn = mod[exported] ?? mod["default"]; + if (typeof fn === "function") { + parse = fn as (src: string) => unknown; + break; + } + } catch { + // not installed — try next + } + } + + if (!parse) { + console.warn(" [yaml] Neither js-yaml nor yaml package found — skipping YAML snippets"); + return snippets.map((s) => ({ + snippet: s, + passed: true, + message: "skipped (no yaml parser available)", + })); + } + + return snippets.map((snippet) => { + try { + parse!(snippet.code); + return { snippet, passed: true, message: "" }; + } catch (err) { + return { + snippet, + passed: false, + message: err instanceof Error ? err.message : String(err), + }; + } + }); +} + +// --------------------------------------------------------------------------- +// MDX file discovery +// --------------------------------------------------------------------------- + +async function findMdxFiles(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }); + const nested = await Promise.all( + entries.map(async (entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + return ignoredDirs.has(entry.name) ? [] : findMdxFiles(full); + } + return entry.isFile() && entry.name.endsWith(".mdx") ? [full] : []; + }), + ); + return nested.flat().sort(); +} + +async function collectSnippets(files: string[]): Promise { + const snippets: Snippet[] = []; + let index = 0; + + for (const file of files) { + const markdown = await readFile(file, "utf8"); + const fencePattern = /^```([A-Za-z0-9_-]+)([^\n]*)\n([\s\S]*?)^```/gm; + let match: RegExpExecArray | null; + + while ((match = fencePattern.exec(markdown)) !== null) { + const rawLang = match[1].toLowerCase(); + const lang = langMap[rawLang]; + if (!lang) continue; + + snippets.push({ + attrs: match[2] ?? "", + code: match[3], + file: path.relative(repoRoot, file), + index, + lang, + line: lineNumberAt(markdown, match.index), + }); + index += 1; + } + } + + return snippets; +} + +// --------------------------------------------------------------------------- +// Failure formatting +// --------------------------------------------------------------------------- + +function formatFailure(r: CheckResult): string { + const loc = `${r.snippet.file}:${r.snippet.line}`; + const header = `[${r.snippet.lang}] ${loc}`; + const divider = "-".repeat(Math.max(header.length, 40)); + return `${header}\n${divider}\n${r.message}`; +} + +// --------------------------------------------------------------------------- +// Utilities +// --------------------------------------------------------------------------- + +function lineNumberAt(text: string, index: number): number { + return text.slice(0, index).split("\n").length; +} + +function normalizeCode(code: string): string { + return code + .replace(/^\s*\/\/\s*\.\.\.\s*$/gm, "") + .replace(/^\s*#\s*\.\.\.\s*$/gm, "") + .replace(/^\s*\.\.\.\s*$/gm, ""); +} + +/** Replace absolute tmp paths with just the basename in compiler output. */ +function stripTmpPaths(output: string, tmp: string, basename: string): string { + return output.split(path.join(tmp, basename)).join(basename); +} + +function groupBy(arr: T[], key: (item: T) => string): Record { + const out: Record = {}; + for (const item of arr) { + const k = key(item); + (out[k] ??= []).push(item); + } + return out; +} + +function countBy(arr: T[], key: (item: T) => string): Record { + const out: Record = {}; + for (const item of arr) { + const k = key(item); + out[k] = (out[k] ?? 0) + 1; + } + return out; +} + +async function commandExists(cmd: string): Promise { + const r = await run("which", [cmd]); + return r.exitCode === 0; +} + +function run(command: string, args: string[]): Promise<{ exitCode: number; output: string }> { + return new Promise((resolve) => { + const child = spawn(command, args, { + cwd: repoRoot, + env: process.env, + shell: false, + }); + let output = ""; + child.stdout.on("data", (c: Buffer) => { output += c.toString(); }); + child.stderr.on("data", (c: Buffer) => { output += c.toString(); }); + child.on("close", (code) => { resolve({ exitCode: code ?? 1, output }); }); + }); +} + +// --------------------------------------------------------------------------- +// Run +// --------------------------------------------------------------------------- + +main().catch((err) => { + console.error(err); + process.exit(1); +}); From 9390999566a0c7b6908c0ab8a94e4807b1a2b3f7 Mon Sep 17 00:00:00 2001 From: Promise Nnamdi Ogazi <162865041+Escelit@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:46:37 +0000 Subject: [PATCH 2/3] fix(snippets): add pinned js-yaml parser and fail on missing YAML tool - Add js-yaml@4.3.2 and @types/js-yaml@4.0.9 as pinned devDependencies - checkYaml now calls process.exit(1) when no parser is found instead of silently passing all YAML snippets with a skipped message --- package.json | 2 ++ pnpm-lock.yaml | 24 ++++++++++++++++++++++++ scripts/check-snippets-extended.ts | 11 +++++------ 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index df1b8cb..9d04bcb 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,9 @@ }, "devDependencies": { "@playwright/test": "^1.49.1", + "@types/js-yaml": "4.0.9", "@types/node": "^22.10.2", + "js-yaml": "4.3.2", "tsx": "^4.19.2", "typescript": "^5.7.2" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 58414f5..bbbd159 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,12 +21,18 @@ importers: '@playwright/test': specifier: ^1.49.1 version: 1.62.1 + '@types/js-yaml': + specifier: 4.0.9 + version: 4.0.9 '@types/node': specifier: ^22.10.2 version: 22.20.1 tsx: specifier: ^4.19.2 version: 4.23.0 + js-yaml: + specifier: 4.3.2 + version: 4.3.2 typescript: specifier: ^5.7.2 version: 5.9.3 @@ -270,6 +276,9 @@ packages: '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/js-yaml@4.0.9': + resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} @@ -318,6 +327,9 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} @@ -572,6 +584,10 @@ packages: engines: {node: '>=8'} hasBin: true + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} + hasBin: true + json-stringify-safe@5.0.1: resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} @@ -953,6 +969,8 @@ snapshots: dependencies: '@types/node': 22.20.1 + '@types/js-yaml@4.0.9': {} + '@types/node@12.20.55': {} '@types/node@22.20.1': @@ -999,6 +1017,8 @@ snapshots: asynckit@0.4.0: {} + argparse@2.0.1: {} + available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 @@ -1279,6 +1299,10 @@ snapshots: json-stringify-safe@5.0.1: {} + js-yaml@4.3.2: + dependencies: + argparse: 2.0.1 + math-intrinsics@1.1.0: {} mime-db@1.52.0: {} diff --git a/scripts/check-snippets-extended.ts b/scripts/check-snippets-extended.ts index 1b80052..602039f 100644 --- a/scripts/check-snippets-extended.ts +++ b/scripts/check-snippets-extended.ts @@ -375,12 +375,11 @@ async function checkYaml(snippets: Snippet[]): Promise { } if (!parse) { - console.warn(" [yaml] Neither js-yaml nor yaml package found — skipping YAML snippets"); - return snippets.map((s) => ({ - snippet: s, - passed: true, - message: "skipped (no yaml parser available)", - })); + console.error( + " [yaml] Neither js-yaml nor yaml package found.\n" + + " Add js-yaml as a dev dependency (pnpm add -D js-yaml @types/js-yaml).", + ); + process.exit(1); } return snippets.map((snippet) => { From f3c0991e1c88d821b5afe77b4f5bf157c58fa61d Mon Sep 17 00:00:00 2001 From: Promise Nnamdi Ogazi <162865041+Escelit@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:07:49 +0000 Subject: [PATCH 3/3] feat: fail check on missing rustc/python/bash instead of silently passing --- scripts/check-snippets-extended.ts | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/scripts/check-snippets-extended.ts b/scripts/check-snippets-extended.ts index 602039f..dd32073 100644 --- a/scripts/check-snippets-extended.ts +++ b/scripts/check-snippets-extended.ts @@ -150,8 +150,11 @@ function checkLanguage(lang: Lang, snippets: Snippet[], tmp: string): Promise { if (!(await commandExists("rustc"))) { - console.warn(" [rust] rustc not found — skipping Rust snippets"); - return snippets.map((s) => ({ snippet: s, passed: true, message: "skipped (rustc not found)" })); + console.error( + " [rust] rustc not found.\n" + + " Install Rust (https://rustup.rs) or use dtolnay/rust-toolchain in CI.", + ); + process.exit(1); } const results: CheckResult[] = []; @@ -207,8 +210,11 @@ async function checkPython(snippets: Snippet[], tmp: string): Promise ({ snippet: s, passed: true, message: "skipped (python not found)" })); + console.error( + " [python] python3/python not found.\n" + + " Install Python 3 or use actions/setup-python in CI.", + ); + process.exit(1); } const results: CheckResult[] = []; @@ -240,8 +246,11 @@ async function checkPython(snippets: Snippet[], tmp: string): Promise { if (!(await commandExists("bash"))) { - console.warn(" [shell] bash not found — skipping shell snippets"); - return snippets.map((s) => ({ snippet: s, passed: true, message: "skipped (bash not found)" })); + console.error( + " [shell] bash not found.\n" + + " Install bash or run on a Linux/macOS environment.", + ); + process.exit(1); } const results: CheckResult[] = [];