claude-harness/dsh/sync/verify-skills.mjs

223 lines
8.1 KiB
JavaScript

#!/usr/bin/env node
/**
* Verify a synced skills tree the way this port consumes it.
*
* node dsh/sync/verify-skills.mjs <skills-dir>
*
* `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>/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 `<skills-dir>`:
* markdown links (`[x](../<skill>/y.md)`) and path-like inline code
* (`references/y.md`, `../<skill>/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: <skill>` line per skill directory and a final
* `verified N skills`. The first problem stops the run: `<skill>/SKILL.md:
* <reason>` 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 <skills-dir>'
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<string, string>, 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 `../<skill>/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 `<skillsRoot>/<skill>/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)
})