From 5876a1ae8ac836bbcd6f52b6d4459d150594a570 Mon Sep 17 00:00:00 2001 From: Chaitanya Sharma Date: Wed, 22 Jul 2026 22:01:45 +0530 Subject: [PATCH] ci: syntax-check fenced code blocks on changed docs An audit of the SmartUI doc set found 118 code blocks that could not parse or compile. Every one would have been caught on the commit that introduced it by a check this simple. Adds scripts/lint-code-blocks.js and a PR workflow that runs it over the docs changed by that PR. What it checks, using the real toolchains: javascript node --check python ast.parse ruby ruby -c bash bash -n json JSON.parse yaml yaml.safe_load_all plus two whole-file checks that caught real defects in the audit: - unbalanced code fences (an unclosed block silently inverts every block after it, so prose renders as code and code as prose) - typographic quotes inside code blocks, which are a hard syntax error in every language Deliberately scoped to files changed by the PR. The repo has pre-existing debt and a repo-wide gate would block every PR on unrelated pages; this stops new breakage landing while the backlog is worked down. `--all` sweeps everything. Care taken to avoid false positives, which would train reviewers to ignore it: - block content is dedented before parsing, because Markdown nests blocks inside lists and TabItems and every indented Python block would otherwise look like an IndentationError - deliberate partial snippets (bare object literals, `key: {` fragments, elision markers) are skipped - JSON samples carrying // comments are stripped before parsing, so the house convention is tolerated while genuinely malformed JSON still fails - languages with no runtime on the runner are skipped, not failed Verified both directions: clean pages report zero, and the known-bad pages still report their real defects. Also fixes the defects this linter found immediately on already-audited pages, which the earlier passes had missed: - `npx smartui exec -P 5000 -- ` in 9 SDK pages (stray quote makes it an unterminated shell string) - 23 unbalanced `set`/`export`/`$env:` credential lines across 10 pages Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_0197inyHsQ3V3CxPicLLvFJy --- .github/workflows/lint-code-blocks.yml | 53 +++++++++ docs/smartui-appium-java-sdk.md | 4 +- docs/smartui-cypress-sdk.md | 6 +- docs/smartui-k6-setup.md | 4 +- docs/smartui-playwright-java-sdk.md | 2 +- docs/smartui-playwright-sdk.md | 8 +- docs/smartui-puppeteer-sdk.md | 8 +- docs/smartui-selenium-java-sdk.md | 4 +- docs/smartui-selenium-js-sdk.md | 8 +- docs/smartui-selenium-ruby-sdk.md | 8 +- docs/smartui-testcafe-sdk.md | 6 +- docs/smartui-wdio-sdk.md | 6 +- docs/smartui-with-semaphore.md | 4 +- scripts/lint-code-blocks.js | 156 +++++++++++++++++++++++++ 14 files changed, 243 insertions(+), 34 deletions(-) create mode 100644 .github/workflows/lint-code-blocks.yml create mode 100755 scripts/lint-code-blocks.js diff --git a/.github/workflows/lint-code-blocks.yml b/.github/workflows/lint-code-blocks.yml new file mode 100644 index 000000000..3022d8c23 --- /dev/null +++ b/.github/workflows/lint-code-blocks.yml @@ -0,0 +1,53 @@ +name: Lint code blocks + +# Syntax-checks fenced code blocks in docs changed by this PR. +# +# Scoped to CHANGED FILES on purpose: the repo has pre-existing debt, and a +# repo-wide gate would block every PR on unrelated pages. This stops new +# breakage landing while the backlog is worked down. To sweep everything: +# node scripts/lint-code-blocks.js --all + +on: + pull_request: + paths: + - 'docs/**/*.md' + - 'docs/**/*.mdx' + - 'scripts/lint-code-blocks.js' + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install PyYAML + run: pip install --quiet pyyaml + + # ruby and bash are already present on ubuntu-latest + + - name: Collect changed docs + id: changed + run: | + git diff --name-only --diff-filter=ACMR \ + "origin/${{ github.base_ref }}" HEAD -- 'docs/**/*.md' 'docs/**/*.mdx' \ + > changed.txt || true + echo "count=$(wc -l < changed.txt | tr -d ' ')" >> "$GITHUB_OUTPUT" + echo "Changed doc files:"; cat changed.txt + + - name: Lint fenced code blocks + if: steps.changed.outputs.count != '0' + run: xargs -a changed.txt node scripts/lint-code-blocks.js + + - name: Nothing to lint + if: steps.changed.outputs.count == '0' + run: echo "No documentation files changed." diff --git a/docs/smartui-appium-java-sdk.md b/docs/smartui-appium-java-sdk.md index 1bbbed7f3..4d27729ad 100644 --- a/docs/smartui-appium-java-sdk.md +++ b/docs/smartui-appium-java-sdk.md @@ -105,14 +105,14 @@ export PROJECT_TOKEN="123456#1234abcd-****-****-****-************" ```bash -set PROJECT_TOKEN=123456#1234abcd-****-****-****-************" +set PROJECT_TOKEN="123456#1234abcd-****-****-****-************" ``` ```powershell -$env:PROJECT_TOKEN=123456#1234abcd-****-****-****-************" +$env:PROJECT_TOKEN="123456#1234abcd-****-****-****-************" ``` diff --git a/docs/smartui-cypress-sdk.md b/docs/smartui-cypress-sdk.md index 6f1e6b005..7e6026048 100644 --- a/docs/smartui-cypress-sdk.md +++ b/docs/smartui-cypress-sdk.md @@ -135,14 +135,14 @@ export PROJECT_TOKEN="123456#1234abcd-****-****-****-************" ```bash -set PROJECT_TOKEN=123456#1234abcd-****-****-****-************" +set PROJECT_TOKEN="123456#1234abcd-****-****-****-************" ``` ```powershell -$env:PROJECT_TOKEN=123456#1234abcd-****-****-****-************" +$env:PROJECT_TOKEN="123456#1234abcd-****-****-****-************" ``` @@ -586,7 +586,7 @@ cy.smartuiSnapshot('Page Loaded'); 2. Check configuration file syntax 3. Try different port if default is in use: ```bash - npx smartui exec -P 5000 -- + npx smartui exec -P 5000 -- ``` 4. Check file permissions for configuration and project files diff --git a/docs/smartui-k6-setup.md b/docs/smartui-k6-setup.md index 0a118d065..70017c502 100644 --- a/docs/smartui-k6-setup.md +++ b/docs/smartui-k6-setup.md @@ -51,14 +51,14 @@ export LT_USERNAME="YOUR_USERNAME" ```bash -set LT_USERNAME=YOUR_USERNAME" +set LT_USERNAME="YOUR_USERNAME" ``` ```powershell -$env:LT_USERNAME=YOUR_USERNAME" +$env:LT_USERNAME="YOUR_USERNAME" ``` diff --git a/docs/smartui-playwright-java-sdk.md b/docs/smartui-playwright-java-sdk.md index e5c5bbb29..c2da37884 100644 --- a/docs/smartui-playwright-java-sdk.md +++ b/docs/smartui-playwright-java-sdk.md @@ -599,7 +599,7 @@ SmartUISnapshot.smartuiSnapshot(driver, "Page Loaded"); 2. Check configuration file syntax 3. Try different port if default is in use: ```bash - npx smartui exec -P 5000 -- + npx smartui exec -P 5000 -- ``` 4. Check file permissions for configuration and project files diff --git a/docs/smartui-playwright-sdk.md b/docs/smartui-playwright-sdk.md index 19f30297d..6ae7bb33c 100644 --- a/docs/smartui-playwright-sdk.md +++ b/docs/smartui-playwright-sdk.md @@ -105,21 +105,21 @@ Setup your project token shown in the **SmartUI** app after creating your projec ```bash -export PROJECT_TOKEN=123456#1234abcd-****-****-****-************" +export PROJECT_TOKEN="123456#1234abcd-****-****-****-************" ``` ```bash -set PROJECT_TOKEN=123456#1234abcd-****-****-****-************" +set PROJECT_TOKEN="123456#1234abcd-****-****-****-************" ``` ```powershell -$env:PROJECT_TOKEN=123456#1234abcd-****-****-****-************" +$env:PROJECT_TOKEN="123456#1234abcd-****-****-****-************" ``` @@ -631,7 +631,7 @@ await smartuiSnapshot.smartuiSnapshot(page, Page Loaded"); 2. Check configuration file syntax 3. Try different port if default is in use: ```bash - npx smartui exec -P 5000 -- + npx smartui exec -P 5000 -- ``` 4. Check file permissions for configuration and project files diff --git a/docs/smartui-puppeteer-sdk.md b/docs/smartui-puppeteer-sdk.md index fd64bcab2..944491c81 100644 --- a/docs/smartui-puppeteer-sdk.md +++ b/docs/smartui-puppeteer-sdk.md @@ -107,21 +107,21 @@ Setup your project token shown in the **SmartUI** app after creating your projec ```bash -export PROJECT_TOKEN=123456#1234abcd-****-****-****-************" +export PROJECT_TOKEN="123456#1234abcd-****-****-****-************" ``` ```bash -set PROJECT_TOKEN=123456#1234abcd-****-****-****-************" +set PROJECT_TOKEN="123456#1234abcd-****-****-****-************" ``` ```powershell -$env:PROJECT_TOKEN=123456#1234abcd-****-****-****-************" +$env:PROJECT_TOKEN="123456#1234abcd-****-****-****-************" ``` @@ -636,7 +636,7 @@ await smartuiSnapshot(page, Page Loaded"); 2. Check configuration file syntax 3. Try different port if default is in use: ```bash - npx smartui exec -P 5000 -- + npx smartui exec -P 5000 -- ``` 4. Check file permissions for configuration and project files diff --git a/docs/smartui-selenium-java-sdk.md b/docs/smartui-selenium-java-sdk.md index 2c2250406..068276d78 100644 --- a/docs/smartui-selenium-java-sdk.md +++ b/docs/smartui-selenium-java-sdk.md @@ -140,7 +140,7 @@ set PROJECT_TOKEN="123456#1234abcd-****-****-****-************" ```powershell -$env:PROJECT_TOKEN=123456#1234abcd-****-****-****-************" +$env:PROJECT_TOKEN="123456#1234abcd-****-****-****-************" ``` @@ -658,7 +658,7 @@ SmartUISnapshot.smartuiSnapshot(driver, "Page Loaded"); 2. Check configuration file syntax 3. Try different port if default is in use: ```bash - npx smartui exec -P 5000 -- + npx smartui exec -P 5000 -- ``` 4. Check file permissions for configuration and project files diff --git a/docs/smartui-selenium-js-sdk.md b/docs/smartui-selenium-js-sdk.md index 967862c81..46553d557 100644 --- a/docs/smartui-selenium-js-sdk.md +++ b/docs/smartui-selenium-js-sdk.md @@ -107,21 +107,21 @@ Setup your project token shown in the **SmartUI** app after creating your projec ```bash -export PROJECT_TOKEN=123456#1234abcd-****-****-****-************" +export PROJECT_TOKEN="123456#1234abcd-****-****-****-************" ``` ```bash -set PROJECT_TOKEN=123456#1234abcd-****-****-****-************" +set PROJECT_TOKEN="123456#1234abcd-****-****-****-************" ``` ```powershell -$env:PROJECT_TOKEN=123456#1234abcd-****-****-****-************" +$env:PROJECT_TOKEN="123456#1234abcd-****-****-****-************" ``` @@ -624,7 +624,7 @@ await smartuiSnapshot(driver, Page Loaded"); 2. Check configuration file syntax 3. Try different port if default is in use: ```bash - npx smartui exec -P 5000 -- + npx smartui exec -P 5000 -- ``` 4. Check file permissions for configuration and project files diff --git a/docs/smartui-selenium-ruby-sdk.md b/docs/smartui-selenium-ruby-sdk.md index d6dce6c64..69ed8cc86 100644 --- a/docs/smartui-selenium-ruby-sdk.md +++ b/docs/smartui-selenium-ruby-sdk.md @@ -111,21 +111,21 @@ Setup your project token shown in the **SmartUI** app after creating your projec ```bash -export PROJECT_TOKEN=123456#1234abcd-****-****-****-************" +export PROJECT_TOKEN="123456#1234abcd-****-****-****-************" ``` ```bash -set PROJECT_TOKEN=123456#1234abcd-****-****-****-************" +set PROJECT_TOKEN="123456#1234abcd-****-****-****-************" ``` ```powershell -$env:PROJECT_TOKEN=123456#1234abcd-****-****-****-************" +$env:PROJECT_TOKEN="123456#1234abcd-****-****-****-************" ``` @@ -613,7 +613,7 @@ LambdaTest::Selenium::Driver.smartui_snapshot(driver, Page Loaded") 2. Check configuration file syntax 3. Try different port if default is in use: ```bash - npx smartui exec -P 5000 -- + npx smartui exec -P 5000 -- ``` 4. Check file permissions for configuration and project files diff --git a/docs/smartui-testcafe-sdk.md b/docs/smartui-testcafe-sdk.md index 90d574473..5f716f6fe 100644 --- a/docs/smartui-testcafe-sdk.md +++ b/docs/smartui-testcafe-sdk.md @@ -114,14 +114,14 @@ export PROJECT_TOKEN="123456#1234abcd-****-****-****-************" ```bash -set PROJECT_TOKEN=123456#1234abcd-****-****-****-************" +set PROJECT_TOKEN="123456#1234abcd-****-****-****-************" ``` ```powershell -$env:PROJECT_TOKEN=123456#1234abcd-****-****-****-************" +$env:PROJECT_TOKEN="123456#1234abcd-****-****-****-************" ``` @@ -595,7 +595,7 @@ test('Take screenshot after page loads', async t ="> { 2. Check configuration file syntax 3. Try different port if default is in use: ```bash - npx smartui exec -P 5000 -- + npx smartui exec -P 5000 -- ``` 4. Check file permissions for configuration and project files diff --git a/docs/smartui-wdio-sdk.md b/docs/smartui-wdio-sdk.md index df25d62d9..2b78dac78 100644 --- a/docs/smartui-wdio-sdk.md +++ b/docs/smartui-wdio-sdk.md @@ -117,14 +117,14 @@ export PROJECT_TOKEN="123456#1234abcd-****-****-****-************" ```bash -set PROJECT_TOKEN=123456#1234abcd-****-****-****-************" +set PROJECT_TOKEN="123456#1234abcd-****-****-****-************" ``` ```powershell -$env:PROJECT_TOKEN=123456#1234abcd-****-****-****-************" +$env:PROJECT_TOKEN="123456#1234abcd-****-****-****-************" ``` @@ -568,7 +568,7 @@ await smartuiSnapshot(driver, 'Page Loaded'); 2. Check configuration file syntax 3. Try different port if default is in use: ```bash - npx smartui exec -P 5000 -- + npx smartui exec -P 5000 -- ``` 4. Check file permissions for configuration and project files diff --git a/docs/smartui-with-semaphore.md b/docs/smartui-with-semaphore.md index b0c4f4bd7..9c14e81f8 100644 --- a/docs/smartui-with-semaphore.md +++ b/docs/smartui-with-semaphore.md @@ -265,13 +265,13 @@ env_vars: jobs: - name: Run Tests commands: - - npx smartui exec -- + - npx smartui exec -- - name: Test Group 2 task: jobs: - name: Run Tests commands: - - npx smartui exec -- + - npx smartui exec -- ``` 3. Optimize test execution diff --git a/scripts/lint-code-blocks.js b/scripts/lint-code-blocks.js new file mode 100755 index 000000000..bd58d4006 --- /dev/null +++ b/scripts/lint-code-blocks.js @@ -0,0 +1,156 @@ +#!/usr/bin/env node +/** + * Syntax-check fenced code blocks in Markdown/MDX docs. + * + * Why this exists: an audit of the SmartUI doc set found 118 code blocks that + * could not parse or compile - missing quotes, JavaScript object literals inside + * Python blocks, unclosed fences, curly quotes. Every one would have been caught + * here on the commit that introduced it. + * + * Usage: + * node scripts/lint-code-blocks.js lint specific files + * node scripts/lint-code-blocks.js --all lint every doc + * + * Exit code 1 if any block fails. + */ +'use strict'; +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execFileSync, spawnSync } = require('child_process'); + +const args = process.argv.slice(2); +const ALL = args.includes('--all'); +let files = args.filter((a) => !a.startsWith('--')); + +if (ALL) { + const walk = (dir) => + fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => { + const p = path.join(dir, e.name); + if (e.isDirectory()) return walk(p); + return /\.mdx?$/.test(e.name) ? [p] : []; + }); + files = ['docs'].filter(fs.existsSync).flatMap(walk); +} +files = files.filter((f) => /\.mdx?$/.test(f) && fs.existsSync(f)); +if (!files.length) { + console.log('lint-code-blocks: no markdown files to check'); + process.exit(0); +} + +// ── which languages we can actually validate, and how ──────────────────────── +const NORMALISE = { + js: 'javascript', node: 'javascript', nodejs: 'javascript', + py: 'python', rb: 'ruby', cs: 'csharp', sh: 'bash', shell: 'bash', zsh: 'bash', +}; +const have = (cmd) => spawnSync(cmd, ['--version'], { stdio: 'ignore' }).status === 0; +const HAVE = { node: true, python3: have('python3'), ruby: have('ruby'), bash: have('bash') }; + +// Markdown nests blocks inside lists and s, so the content arrives +// uniformly indented. Strip the common leading whitespace before parsing - +// otherwise every indented Python block is a false IndentationError. +function dedent(src) { + const ls = src.split('\n').filter((l) => l.trim()); + if (!ls.length) return src; + const pad = Math.min(...ls.map((l) => l.match(/^[ \t]*/)[0].length)); + return pad ? src.split('\n').map((l) => l.slice(pad)).join('\n') : src; +} + +function tmp(content, ext) { + const f = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'blk-')), 'block' + ext); + fs.writeFileSync(f, content); + return f; +} +function run(cmd, argv) { + const r = spawnSync(cmd, argv, { encoding: 'utf8' }); + return { ok: r.status === 0, err: ((r.stderr || '') + (r.stdout || '')).trim().split('\n')[0] }; +} + +const CHECKERS = { + javascript: (c) => (HAVE.node ? run('node', ['--check', tmp(c, '.mjs')]) : null), + python: (c) => (HAVE.python3 ? run('python3', ['-c', 'import ast,sys;ast.parse(open(sys.argv[1]).read())', tmp(c, '.py')]) : null), + ruby: (c) => (HAVE.ruby ? run('ruby', ['-c', tmp(c, '.rb')]) : null), + bash: (c) => (HAVE.bash ? run('bash', ['-n', tmp(c, '.sh')]) : null), + json: (c) => { try { JSON.parse(c); return { ok: true }; } catch (e) { return { ok: false, err: e.message }; } }, + yaml: (c) => (HAVE.python3 ? run('python3', ['-c', 'import yaml,sys;list(yaml.safe_load_all(open(sys.argv[1])))', tmp(c, '.yml')]) : null), +}; + +// Blocks that are deliberately partial. A bare object/array fragment or an +// elision marker is legitimate in docs and must not fail the build. +function isFragment(code, lang) { + const t = code.trim(); + if (/^\s*(\.\.\.|\/\/\s*\.\.\.|#\s*\.\.\.)/m.test(t)) return true; + if (lang === 'javascript' && /^['"][^'"]+['"]\s*:/.test(t)) return true; // 'LT:Options': { ... } + if (lang === 'javascript' && /^\{[\s\S]*\}$/.test(t) && !/[;=]/.test(t)) return true; + if (lang === 'json' && /^\s*"[^"]+"\s*:/.test(t) && !t.startsWith('{')) return true; + if (/^[\w.$-]+\s*:\s*[{[]/.test(t)) return true; // bare `key: {` fragment + if (/^(await |const |let |var )?[\w.$]+\([\s\S]*\)[;,]?$/.test(t) && !/\n/.test(t)) return false; + return false; +} +// JSON samples in docs conventionally carry // comments. Strip before parsing, +// but still flag genuinely malformed JSON. +const stripJsonComments = (s) => s.replace(/^\s*\/\/.*$/gm, '').replace(/,(\s*[}\]])/g, '$1'); + +let failures = 0, checked = 0, skipped = 0; + +for (const file of files) { + const lines = fs.readFileSync(file, 'utf8').split('\n'); + let open = false, lang = '', buf = [], start = 0, fences = 0; + + for (let i = 0; i < lines.length; i++) { + const m = /^\s*```(.*)$/.exec(lines[i]); + if (m) { + fences++; + if (!open) { + open = true; start = i + 1; buf = []; + lang = (m[1].trim().split(/\s+/)[0] || '').toLowerCase(); + } else { + open = false; + const norm = NORMALISE[lang] || lang; + const code = dedent(buf.join('\n')); + const checker = CHECKERS[norm]; + if (checker && code.trim()) { + if (isFragment(code, norm)) { skipped++; continue; } + const payload = norm === 'json' ? stripJsonComments(code) : code; + const res = checker(payload); + if (res === null) { skipped++; continue; } // no runtime available + checked++; + if (!res.ok) { + failures++; + console.error(`\n✖ ${file}:${start} [${norm}]`); + console.error(` ${res.err}`); + console.error(` ${code.trim().split('\n')[0].slice(0, 90)}`); + } + } + } + continue; + } + if (open) buf.push(lines[i]); + } + + if (fences % 2 !== 0) { + failures++; + console.error(`\n✖ ${file} unbalanced code fences (${fences}) — a block is never closed`); + console.error(' This silently inverts every following block: prose renders as code and code as prose.'); + } + // Typographic quotes inside code blocks are a hard syntax error in every language. + const smart = []; + let inb = false; + lines.forEach((ln, i) => { + if (/^\s*```/.test(ln)) { inb = !inb; return; } + if (inb && /[‘’“”]/.test(ln)) smart.push(i + 1); + }); + if (smart.length) { + failures++; + console.error(`\n✖ ${file} curly quotes inside code blocks at line(s) ${smart.slice(0, 8).join(', ')}${smart.length > 8 ? '…' : ''}`); + console.error(' Replace ‘ ’ “ ” with ASCII \' and ".'); + } +} + +const unavailable = Object.entries(HAVE).filter(([, v]) => !v).map(([k]) => k); +console.log( + `\nlint-code-blocks: ${checked} blocks checked, ${skipped} skipped (fragment or no runtime), ` + + `${failures} problem(s) in ${files.length} file(s)` + + (unavailable.length ? `\n note: no runtime for ${unavailable.join(', ')} — those blocks were skipped` : '') +); +process.exit(failures ? 1 : 0);