DSH port v2: доставка superpowers вариантом C (sync-по-пину, самопроверка бандла) (#69) #72

Merged
aep merged 19 commits from feat/69-dsh-port-v2 into main 2026-10-25 00:12:57 +03:00
2 changed files with 362 additions and 0 deletions
Showing only changes of commit eef23452ea - Show all commits

222
dsh/sync/verify-skills.mjs Normal file
View File

@ -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 <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)
})

140
dsh/tests/verify.test.mjs Normal file
View File

@ -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)
}
})