diff --git a/dsh/sync/verify-skills.mjs b/dsh/sync/verify-skills.mjs new file mode 100644 index 0000000..a1d8693 --- /dev/null +++ b/dsh/sync/verify-skills.mjs @@ -0,0 +1,222 @@ +#!/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. + * 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`). A + * sibling skill's `scripts/` invocation only needs that skill directory + * to exist: the reduced vendor tree under `dsh/tests/fixtures/` keeps + * the upstream command without shipping the executable, and a mistyped + * skill name still fails. Names that are illustrative examples rather + * than real files (`scripts/tool.sh`, …) are skipped, as in the + * vendored reference implementation. + * + * 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. + */ +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. + * + * @returns {{metadata: Record, body: 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 = {} + 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) + } + metadata[match[1]] = value + } + return { metadata, body } +} + +/** 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()) +} + +/** + * Classify one whitespace-separated token of an inline code span. + * + * `file` — a path that must resolve; `sibling-script` — a `..//scripts/` + * invocation, where only the skill directory has to exist (see the header). + * + * @returns {{kind: 'file' | 'sibling-script', path: string} | null} + */ +function inlineReference(token) { + if (/^(?:references|scripts)\//.test(token)) return { kind: 'file', path: token } + if (!token.startsWith('../')) return null + const parts = token.split('/') + const scripts = parts.indexOf('scripts') + if (scripts > 0) return { kind: 'sibling-script', path: parts.slice(0, scripts).join('/') } + return { kind: 'file', path: token } +} + +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.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 + const target = resolve(skillDir, reference.path) + const resolved = reference.kind === 'sibling-script' ? await isDirectory(target) : await exists(target) + if (!resolved) 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) +}) diff --git a/dsh/tests/verify.test.mjs b/dsh/tests/verify.test.mjs new file mode 100644 index 0000000..f13b8d0 --- /dev/null +++ b/dsh/tests/verify.test.mjs @@ -0,0 +1,140 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { cpSync, readFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' +import { cleanup, tempDir, write } from './helpers.mjs' + +const testDir = dirname(fileURLToPath(import.meta.url)) +const repoRoot = resolve(testDir, '..', '..') +const syncScript = resolve(repoRoot, 'dsh/sync/sync-from-upstream.mjs') +const verifyScript = resolve(repoRoot, 'dsh/sync/verify-skills.mjs') +const fixture = resolve(testDir, 'fixtures', 'superpowers-min') + +function run(script, args) { + return spawnSync(process.execPath, [script, ...args], { encoding: 'utf8' }) +} + +/** + * The tree every test starts from: Task 1's sync applied to the reduced + * upstream checkout — 15 skill directories plus the DSH `dsh-tools.md` + * override. Verify only ever sees a synced tree, never raw upstream text. + */ +function syncedTree(work) { + const out = join(work, 'synced') + const result = run(syncScript, [fixture, '--out', out, '--ref', 'v7.0.1']) + assert.equal(result.status, 0, `sync fixture failed: ${result.stderr}`) + return out +} + +/** A private copy of a synced tree: the failing tests mutate it. */ +function syncedCopy(work, dir) { + const copy = join(work, 'copy') + cpSync(dir, copy, { recursive: true }) + return copy +} + +function verify(dir) { + return run(verifyScript, [dir]) +} + +test('verify ok on synced tree', () => { + const work = tempDir('dsh-verify-ok-') + try { + const result = verify(syncedTree(work)) + + assert.equal(result.status, 0, `stderr: ${result.stderr}`) + assert.match(result.stdout, /^ok: using-superpowers$/m) + assert.match(result.stdout, /verified 15 skills/) + assert.equal((result.stdout.match(/^ok: /gm) ?? []).length, 15, 'one ok line per skill directory') + } finally { + cleanup(work) + } +}) + +test('verify fails on missing description', () => { + const work = tempDir('dsh-verify-no-description-') + try { + const dir = syncedCopy(work, syncedTree(work)) + const file = join(dir, 'brainstorming', 'SKILL.md') + write(file, readFileSync(file, 'utf8').replace(/^description:.*\n/m, '')) + + const result = verify(dir) + + assert.equal(result.status, 1) + assert.match(result.stderr, /brainstorming\/SKILL\.md/) + assert.match(result.stderr, /description/) + } finally { + cleanup(work) + } +}) + +test('verify fails on leftover superpowers: prefix in a body', () => { + const work = tempDir('dsh-verify-prefix-') + try { + const dir = syncedCopy(work, syncedTree(work)) + const file = join(dir, 'systematic-debugging', 'SKILL.md') + write(file, `${readFileSync(file, 'utf8')}\nSee superpowers:brainstorming for the upstream wording.\n`) + + const result = verify(dir) + + assert.equal(result.status, 1) + assert.match(result.stderr, /systematic-debugging\/SKILL\.md/) + assert.match(result.stderr, /superpowers:/) + } finally { + cleanup(work) + } +}) + +test('verify fails on dangling relative reference', () => { + const work = tempDir('dsh-verify-dangling-') + try { + const dir = syncedCopy(work, syncedTree(work)) + const file = join(dir, 'writing-plans', 'SKILL.md') + write(file, `${readFileSync(file, 'utf8')}\nSee [the missing file](../brainstorming/nope.md).\n`) + + const result = verify(dir) + + assert.equal(result.status, 1) + assert.match(result.stderr, /writing-plans\/SKILL\.md/) + assert.match(result.stderr, /\.\.\/brainstorming\/nope\.md/) + } finally { + cleanup(work) + } +}) + +test('verify ignores illustrative example script paths', () => { + const work = tempDir('dsh-verify-illustrative-') + try { + const dir = syncedCopy(work, syncedTree(work)) + const file = join(dir, 'writing-skills', 'SKILL.md') + write( + file, + `${readFileSync(file, 'utf8')}\nInvoke bundled scripts in prose: \`bash scripts/tool.sh\`, \`node scripts/tool.js\`.\n`, + ) + + const result = verify(dir) + + assert.equal(result.status, 0, `stderr: ${result.stderr}`) + assert.match(result.stdout, /verified 15 skills/) + } finally { + cleanup(work) + } +}) + +test('verify ok when superpowers: survives in a references file (T1.5)', () => { + const work = tempDir('dsh-verify-references-') + try { + const dir = syncedTree(work) + const reference = readFileSync(join(dir, 'using-superpowers', 'references', 'dsh-tools.md'), 'utf8') + assert.match(reference, /superpowers:/, 'the DSH tool mapping quotes the upstream namespace on purpose') + + const result = verify(dir) + + assert.equal(result.status, 0, `stderr: ${result.stderr}`) + assert.match(result.stdout, /verified 15 skills/) + } finally { + cleanup(work) + } +})