223 lines
8.1 KiB
JavaScript
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)
|
|
})
|