|
| 1 | +/** |
| 2 | + * Bash syntax validation for SKILL.md code blocks (gate, free). |
| 3 | + * |
| 4 | + * Extracts every ```bash...``` block from all generated SKILL.md files and |
| 5 | + * runs `bash -n` (parse-only, no execution) on each one. Catches unclosed |
| 6 | + * code fences, broken redirects (e.g. `2/dev/null` instead of `2>/dev/null`), |
| 7 | + * unclosed strings, and other syntactic mistakes before they reach users. |
| 8 | + * |
| 9 | + * Preprocessing: angle-bracket placeholder tokens like <branch-name> are |
| 10 | + * replaced with the bareword PLACEHOLDER before validation. Without this |
| 11 | + * substitution, `<word>` is parsed as a shell redirect (read stdin from |
| 12 | + * file "word>") and would produce false positive errors on intentional |
| 13 | + * template placeholders that agents fill in at runtime. |
| 14 | + * |
| 15 | + * Covers all 53 SKILL.md files discovered via discoverSkillFiles(). |
| 16 | + * Runs in < 2s with no network or API calls. |
| 17 | + */ |
| 18 | + |
| 19 | +import { describe, test, expect } from 'bun:test'; |
| 20 | +import * as fs from 'fs'; |
| 21 | +import * as path from 'path'; |
| 22 | + |
| 23 | +const ROOT = path.resolve(import.meta.dir, '..'); |
| 24 | + |
| 25 | +const SKIP_DIRS = new Set(['node_modules', '.git', 'dist']); |
| 26 | + |
| 27 | +function discoverSkillFiles(root: string): string[] { |
| 28 | + const subdirs = fs.readdirSync(root, { withFileTypes: true }) |
| 29 | + .filter(d => d.isDirectory() && !d.name.startsWith('.') && !SKIP_DIRS.has(d.name)) |
| 30 | + .map(d => d.name); |
| 31 | + |
| 32 | + const results: string[] = []; |
| 33 | + if (fs.existsSync(path.join(root, 'SKILL.md'))) { |
| 34 | + results.push('SKILL.md'); |
| 35 | + } |
| 36 | + for (const dir of subdirs) { |
| 37 | + const rel = `${dir}/SKILL.md`; |
| 38 | + if (fs.existsSync(path.join(root, rel))) { |
| 39 | + results.push(rel); |
| 40 | + } |
| 41 | + } |
| 42 | + return results; |
| 43 | +} |
| 44 | + |
| 45 | +interface BashBlock { |
| 46 | + code: string; |
| 47 | + lineNo: number; // 1-indexed line number where the block content starts |
| 48 | +} |
| 49 | + |
| 50 | +function extractBashBlocks(content: string): BashBlock[] { |
| 51 | + const lines = content.split('\n'); |
| 52 | + const blocks: BashBlock[] = []; |
| 53 | + let inBash = false; |
| 54 | + let blockLines: string[] = []; |
| 55 | + let startLine = 0; |
| 56 | + |
| 57 | + for (let i = 0; i < lines.length; i++) { |
| 58 | + const line = lines[i]; |
| 59 | + if (!inBash && line.trimStart().startsWith('```bash')) { |
| 60 | + inBash = true; |
| 61 | + blockLines = []; |
| 62 | + startLine = i + 2; // +1 for 1-index, +1 to skip the fence line itself |
| 63 | + continue; |
| 64 | + } |
| 65 | + if (inBash && line.trimStart() === '```') { |
| 66 | + blocks.push({ code: blockLines.join('\n'), lineNo: startLine }); |
| 67 | + inBash = false; |
| 68 | + continue; |
| 69 | + } |
| 70 | + if (inBash) { |
| 71 | + blockLines.push(line); |
| 72 | + } |
| 73 | + } |
| 74 | + return blocks; |
| 75 | +} |
| 76 | + |
| 77 | +// Replace <placeholder> tokens with the bareword PLACEHOLDER to prevent |
| 78 | +// bash from interpreting them as stdin redirects, which produce false errors. |
| 79 | +function preprocess(code: string): string { |
| 80 | + return code.replace(/<[A-Za-z][A-Za-z0-9_.-]*>/g, 'PLACEHOLDER'); |
| 81 | +} |
| 82 | + |
| 83 | +function checkBashSyntax(code: string): string | null { |
| 84 | + const result = Bun.spawnSync(['bash', '-n'], { |
| 85 | + stdin: Buffer.from(code), |
| 86 | + stdout: 'pipe', |
| 87 | + stderr: 'pipe', |
| 88 | + }); |
| 89 | + if (result.exitCode !== 0) { |
| 90 | + return result.stderr.toString().trim(); |
| 91 | + } |
| 92 | + return null; |
| 93 | +} |
| 94 | + |
| 95 | +describe('bash syntax validation in SKILL.md code blocks', () => { |
| 96 | + const skillFiles = discoverSkillFiles(ROOT); |
| 97 | + |
| 98 | + // Sanity: we should find at least 10 skill files |
| 99 | + test('discovers skill files', () => { |
| 100 | + expect(skillFiles.length).toBeGreaterThanOrEqual(10); |
| 101 | + }); |
| 102 | + |
| 103 | + for (const relPath of skillFiles) { |
| 104 | + test(`${relPath} — all bash blocks pass bash -n`, () => { |
| 105 | + const content = fs.readFileSync(path.join(ROOT, relPath), 'utf-8'); |
| 106 | + const blocks = extractBashBlocks(content); |
| 107 | + |
| 108 | + const errors: string[] = []; |
| 109 | + for (const { code, lineNo } of blocks) { |
| 110 | + const preprocessed = preprocess(code); |
| 111 | + const errMsg = checkBashSyntax(preprocessed); |
| 112 | + if (errMsg !== null) { |
| 113 | + // Trim bash's "bash: line N:" prefix and replace with our own location |
| 114 | + const cleaned = errMsg.replace(/^bash: line \d+: /, ''); |
| 115 | + errors.push(` ~line ${lineNo}: ${cleaned}`); |
| 116 | + } |
| 117 | + } |
| 118 | + |
| 119 | + if (errors.length > 0) { |
| 120 | + throw new Error( |
| 121 | + `${relPath}: ${errors.length} bash block(s) failed syntax check:\n` + |
| 122 | + errors.join('\n') |
| 123 | + ); |
| 124 | + } |
| 125 | + }); |
| 126 | + } |
| 127 | +}); |
0 commit comments