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

236 lines
8.8 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.
* 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 `<skills-dir>`:
* markdown links (`[x](../<skill>/y.md)`) and path-like inline code
* (`references/y.md`, `../<skill>/references/y.md`, `scripts/y`,
* `../<skill>/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: <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.
*
* 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<string, string>, 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 `<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.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)
})