#!/usr/bin/env node /** * Verify a synced skills tree the way this port consumes it. * * node dsh/sync/verify-skills.mjs * * `install.sh` runs this between `sync-from-upstream.mjs` and the copy into * `$DSH_HOME/skills`: a tree that fails here is never installed. * * Checks, per `/SKILL.md`: * * 1. YAML frontmatter parses and carries non-empty scalar `name` and * `description` — the fields the DSH skill catalog reads. The parser is * the scalar one vendored from LayneChai's `parseFrontmatter` (MIT); no * provider is imported, because on the DeepSeek Harness the registry * belongs to the built-in `skill-filesystem`, not to this repository. * A duplicate scalar key is an error too: the last value silently wins * in a naive parser, and a name/description pair that appears twice is * a broken skill file, not a style choice. * 2. The body carries no leftover upstream `superpowers:` namespace * prefix — the port strips it during sync, and the DSH registry * addresses skills by bare name. * 3. Every relative reference in the body resolves inside ``: * markdown links (`[x](..//y.md)`) and path-like inline code * (`references/y.md`, `..//references/y.md`, `scripts/y`, * `..//scripts/y`). Both the skill directory and the file must * exist — a renamed or dropped upstream script is exactly the * regression this check exists for (T2.1/T2.2). Names that are * illustrative examples rather than real files (`scripts/tool.sh`, …) * are skipped, as in the vendored reference implementation; that * whitelist is a deliberate compromise, not a general escape hatch — * it covers exactly four literals and hides nothing real in v7.0.1 * (T2.3/T2.8 in the SDD ledger). * * The prefix check is scoped to SKILL.md bodies on purpose: reference files * such as `using-superpowers/references/dsh-tools.md` quote `superpowers:` in * prose, and that is legal — the mapping table names the upstream namespace * on purpose (T1.5). Fenced code blocks are treated as examples, not prose, * exactly as the vendored reference implementation does. * * Output: one `ok: ` line per skill directory and a final * `verified N skills`. The first problem stops the run: `/SKILL.md: * ` on stderr, exit 1. * * @module dsh/sync/verify-skills */ import { readdir, readFile, stat } from 'node:fs/promises' import { join, resolve } from 'node:path' const USAGE = 'usage: node dsh/sync/verify-skills.mjs ' const MARKDOWN_LINK = /\[[^\]]*\]\(([^)\s]+)\)/g const INLINE_CODE = /`([^`\n]+)`/g /** Trailing punctuation a path picks up from the surrounding sentence. */ const TRAILING_PUNCTUATION = /[.,;:)\]]+$/ /** * Names that appear only as illustrative examples in prose, never as real * files — the same list the vendored reference implementation keeps. * * The protection is deliberately narrow (T2.3/T2.8): it skips exactly these * four literals, matched as whole inline-code tokens — not a prefix, not a * glob, not a path segment. Checked on obra/superpowers v7.0.1: no real * upstream link or path uses any of these names, so nothing that exists is * skipped today. A new name here needs the same evidence. */ const ILLUSTRATIVE = new Set(['scripts/tool.sh', 'scripts/tool.js', 'scripts/helper.py', 'scripts/helper.sh']) /** * Parse a scalar-only YAML frontmatter block, same rules as the vendored * `parseFrontmatter`: the file starts with `---`, the block ends at the first * line that starts with `---`, and each `key: value` line becomes a string * (matching quotes stripped). Anything else is left for a real YAML parser. * Keys seen more than once are collected in `duplicates`: the vendored parser * would let the last value win silently, and a duplicated `name` or * `description` is a broken skill file. * * @returns {{metadata: Record, body: string, duplicates: string[]} | null} */ function parseFrontmatter(text) { if (!text.startsWith('---')) return null const end = text.indexOf('\n---', 3) if (end === -1) return null const block = text.slice(3, end) const body = text.slice(end + 4).replace(/^\n+/, '') const metadata = {} const duplicates = [] for (const line of block.split('\n')) { const match = /^([A-Za-z][\w-]*):\s*(.*)$/.exec(line.trim()) if (!match) continue let value = match[2].trim() if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { value = value.slice(1, -1) } if (Object.hasOwn(metadata, match[1])) duplicates.push(match[1]) metadata[match[1]] = value } return { metadata, body, duplicates } } /** Map every line inside a ``` / ~~~ fence to an empty line: examples are not prose. */ function blankFences(text) { let fenced = false return text .split('\n') .map((line) => { if (/^\s*(```|~~~)/.test(line)) { fenced = !fenced return '' } return fenced ? '' : line }) .join('\n') } /** True when a markdown link target names no in-tree file to resolve. */ function isExternal(target) { return target.startsWith('#') || target.startsWith('/') || /^[a-z][a-z0-9+.-]*:/i.test(target) } async function statOrNull(path) { return stat(path).catch(() => null) } async function exists(path) { return Boolean(await statOrNull(path)) } async function isDirectory(path) { return Boolean((await statOrNull(path))?.isDirectory()) } /** * Return the in-tree reference a whitespace-separated inline-code token names, * or `null` when it is prose (`foo`, `PLAN_FILE`, `skills/…` from the checkout * root) rather than a path relative to the skill directory. * * @returns {string | null} */ function inlineReference(token) { if (/^(?:references|scripts)\//.test(token)) return token if (token.startsWith('../')) return token return null } function fail(skill, reason) { console.error(`${skill}/SKILL.md: ${reason}`) process.exit(1) } /** Verify one `//SKILL.md`; exits the process on the first problem. */ async function verifySkill(skillsRoot, skill) { const skillDir = join(skillsRoot, skill) const skillFile = join(skillDir, 'SKILL.md') let text try { text = await readFile(skillFile, 'utf8') } catch (error) { fail(skill, `cannot read SKILL.md (${error?.code ?? error})`) } const parsed = parseFrontmatter(text) if (parsed === null) fail(skill, 'missing YAML frontmatter') if (parsed.duplicates.length > 0) fail(skill, `duplicate key "${parsed.duplicates[0]}" in frontmatter`) if (!parsed.metadata.name) fail(skill, 'empty name in frontmatter') if (!parsed.metadata.description) fail(skill, 'empty description in frontmatter') // The body is a suffix of the file, so the number of newlines before it is // the line offset: report file lines, not body lines, to the operator. const lineOffset = (text.slice(0, text.length - parsed.body.length).match(/\n/g) ?? []).length const lines = blankFences(parsed.body).split('\n') for (const [index, line] of lines.entries()) { const lineNumber = lineOffset + index + 1 if (line.includes('superpowers:')) { fail(skill, `leftover superpowers: namespace prefix in body (line ${lineNumber})`) } for (const match of line.matchAll(MARKDOWN_LINK)) { const target = match[1] if (isExternal(target)) continue if (!(await exists(resolve(skillDir, target.replace(/[?#].*$/, ''))))) { fail(skill, `dangling relative reference: ${target} (line ${lineNumber})`) } } for (const code of line.matchAll(INLINE_CODE)) { for (const raw of code[1].split(/\s+/)) { const token = raw.replace(TRAILING_PUNCTUATION, '') if (token === '' || ILLUSTRATIVE.has(token)) continue const reference = inlineReference(token) if (reference === null) continue if (!(await exists(resolve(skillDir, reference)))) { fail(skill, `dangling relative reference: ${token} (line ${lineNumber})`) } } } } } async function main() { if (process.argv.length > 3) { console.error(USAGE) process.exit(2) } const arg = process.argv[2] if (!arg) { console.error(USAGE) process.exit(2) } const skillsRoot = resolve(arg) if (!(await isDirectory(skillsRoot))) { console.error(`${skillsRoot}: not a skills directory`) process.exit(1) } const skills = (await readdir(skillsRoot, { withFileTypes: true })) .filter((entry) => entry.isDirectory()) .map((entry) => entry.name) .sort() for (const skill of skills) await verifySkill(skillsRoot, skill) for (const skill of skills) console.log(`ok: ${skill}`) console.log(`verified ${skills.length} skills`) } main().catch((error) => { console.error(`\n[FAIL] unexpected error: ${error?.stack ?? error}\n`) process.exit(1) })