import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync, readdirSync, existsSync, mkdirSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { createRequire } from 'node:module'; import { join, resolve } from 'node:path'; import { tmpdir } from 'node:os'; // DSH bundle 的守卫测试:package.json 的 `dsh.bundle`、补丁里唯一一行 preset 声明、子插件 // 包名白名单、不继承默认技能根的 skill-filesystem 配置与 REV_SKILLS_DIR 覆盖逻辑、文件卫生。 // 不断言技能计数:预设必须与技能增删解耦(技能数由 tests/counts.test.mjs 看住)。 const DSH_DIR = fileURLToPath(new URL('../', import.meta.url)); const PACKAGE_FILE = join(DSH_DIR, 'package.json'); const PATCH_FILE = join(DSH_DIR, 'dsh.patch.yml'); const PATCH_TEXT = readPatchText(); // 顶层是 `- insert:` 一行,其值才是插入的行列表 const TOP = parseYaml(PATCH_TEXT); const ROWS = nodeSeq(nodeMap(TOP.value[0]).get('insert')); assert.equal(ROWS.length, 1, '补丁顶层应是一条 insert 行'); const PRESET_ROW = ROWS[0]; const PRESET_CONFIG = nodeMap(PRESET_ROW.value.get('config')); // 子插件清单;分组(cordis:group)的 config 本身又是一层子清单 const PLUGINS = nodeSeq(PRESET_CONFIG.get('plugins')); // 补丁允许出现的全部子插件包名(含 cordis 内置的分组标记)。写死在此处,包名拼错即红。 const ALLOWED_PACKAGES = new Set([ 'cordis:group', '@deepseek-ai/dsh-persona', '@deepseek-ai/dsh-agent-instructions', '@deepseek-ai/dsh-tool-bash', '@deepseek-ai/dsh-tool-pwsh', '@deepseek-ai/dsh-tool-fs', '@deepseek-ai/dsh-tool-fs-search', '@deepseek-ai/dsh-tool-jobs', '@deepseek-ai/dsh-command-goal', '@deepseek-ai/dsh-tool-goal', '@deepseek-ai/dsh-compaction-basic', '@deepseek-ai/dsh-command-compact', '@deepseek-ai/dsh-compaction-tool-result-pruner', '@deepseek-ai/dsh-tool-subagent-control', '@deepseek-ai/dsh-tool-subagent-control/list-agents', '@deepseek-ai/dsh-tool-subagent', '@deepseek-ai/dsh-workflow-ptc', '@deepseek-ai/dsh-tool-workflow', '@deepseek-ai/dsh-tool-ask-user', '@deepseek-ai/dsh-tool-todo', '@deepseek-ai/dsh-tool-web', '@deepseek-ai/dsh-skill-filesystem', '@deepseek-ai/dsh-tool-skill', '@deepseek-ai/dsh-tool-present', '@deepseek-ai/dsh-plugin-manager/tools', ]); // 官方已发布的 preset id:本 bundle 不得与之冲突(Duplicate preset IDs fail declaration loading)。 const SHIPPED_PRESET_IDS = ['standard', 'minimal', 'ptc', 'cordis']; function readPatchText() { // 统一成 LF:文件本身必须已是 LF(有单独用例断言) return readFileSync(PATCH_FILE, 'utf8').replace(/\r\n/g, '\n'); } // 覆盖本补丁用到的 YAML 子集:映射、序列、块标量、`!!js` 标量。缩进为空格。 // 一切值都包成带 type 的节点,map/seq 的类型不会在传递中丢失。 function parseYaml(text) { const lines = text.split('\n').filter((l) => l.trim() !== '' && !l.trimStart().startsWith('#')); let i = 0; const indentOf = (l) => l.length - l.trimStart().length; const unquote = (s) => { if (s.startsWith('"')) return JSON.parse(s); if (s.startsWith("'")) return s.slice(1, -1); return s; }; const scalar = (raw) => { const s = raw.trim(); const js = /^!!js\s+([\s\S]+)$/.exec(s); if (js) return { type: 'scalar', value: { js: unquote(js[1].trim()) } }; if (s.startsWith('"') || s.startsWith("'")) return { type: 'scalar', value: unquote(s) }; if (s === 'true') return { type: 'scalar', value: true }; if (s === 'false') return { type: 'scalar', value: false }; if (/^-?\d+$/.test(s)) return { type: 'scalar', value: Number(s) }; return { type: 'scalar', value: s }; }; const parseBlock = () => { const ind = indentOf(lines[i]); if (lines[i].slice(ind).startsWith('- ')) return parseSeq(ind); return parseMap(ind); }; // `key: value`:值是同行标量、块标量(`>` / `|`)、或更深缩进的子块(映射 / 序列) const parseEntry = (key, raw, ind, map) => { if (/^[>|][+-]?$/.test(raw)) { const block = []; while (i < lines.length && indentOf(lines[i]) > ind) { block.push(lines[i].trim()); i++; } map.set(key, { type: 'scalar', value: raw.startsWith('>') ? block.join(' ') : block.join('\n') }); return; } if (raw !== '') { map.set(key, scalar(raw)); return; } map.set(key, i < lines.length && indentOf(lines[i]) > ind ? parseBlock() : scalar('')); }; // 序列项为 `- key: value`(可带同层后续键)或 `- 标量` const parseSeq = (ind) => { const items = []; while (i < lines.length) { const d = indentOf(lines[i]); if (d !== ind || !lines[i].slice(d).startsWith('- ')) break; const rest = lines[i].slice(d + 2); const m = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(rest); i++; if (!m) { items.push(scalar(rest)); continue; } const map = new Map(); parseEntry(m[1], m[2], d, map); while (i < lines.length && indentOf(lines[i]) > d) { const cd = indentOf(lines[i]); const cm = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(lines[i].slice(cd)); assert.ok(cm, `补丁行无法解析为键值:${JSON.stringify(lines[i])}`); i++; parseEntry(cm[1], cm[2], cd, map); } items.push({ type: 'map', value: map }); } return { type: 'seq', value: items }; }; const parseMap = (ind) => { const map = new Map(); while (i < lines.length) { const d = indentOf(lines[i]); if (d !== ind) break; const m = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(lines[i].slice(d)); if (!m) break; i++; parseEntry(m[1], m[2], ind, map); } return { type: 'map', value: map }; }; return parseBlock(); } function nodeValue(node) { return node && node.type === 'scalar' ? node.value : undefined; } function nodeMap(node) { return node && node.type === 'map' ? node.value : undefined; } function nodeSeq(node) { return node && node.type === 'seq' ? node.value : undefined; } function collectPlugins(nodes) { const out = []; const walk = (list) => { for (const n of list) { if (!n || n.type !== 'map') continue; const name = nodeValue(n.value.get('name')); if (name) out.push({ id: nodeValue(n.value.get('id')), name, configNode: n.value.get('config') }); const children = nodeSeq(n.value.get('config')); if (children) walk(children); } }; walk(nodes); return out; } function allPlugins() { return collectPlugins(PLUGINS); } function findPlugin(name) { return allPlugins().filter((p) => p.name === name); } // skill-filesystem 的 rev-skills 技能根表达式(唯一一项 customSkillDirs) function skillRootExpr() { const sf = findPlugin('@deepseek-ai/dsh-skill-filesystem'); assert.equal(sf.length, 1, `应有且只有一个 skill-filesystem 行,实际 ${sf.length}`); return nodeValue(nodeSeq(nodeMap(sf[0].configNode).get('customSkillDirs'))[0]).js; } function mutateEnv(vars, fn) { const saved = vars.map((k) => [k, process.env[k]]); for (const [k, v] of vars) { if (v === undefined) delete process.env[k]; else process.env[k] = v; } try { return fn(); } finally { for (const [k, v] of saved) { if (v === undefined) delete process.env[k]; else process.env[k] = v; } } } function evalExpr(expr, baseUrl, opts = {}) { // 未提供的变量一律删除(而不是置空串):置空串会让 DSH_HOME 回落到真实用户目录 const env = { REV_SKILLS_DIR: opts.revSkillsDir, DSH_HOME: opts.dshHome }; return mutateEnv(Object.keys(env).map((k) => [k, env[k]]), () => { const fn = new Function('baseUrl', 'require', 'ctx', `return (${expr});`); return fn(baseUrl, createRequire(baseUrl), undefined); }); } test('根 package.json 是合法 bundle 清单', () => { const pkg = JSON.parse(readFileSync(PACKAGE_FILE, 'utf8')); assert.equal(pkg.name, 'rev-skills'); assert.match(pkg.version, /^\d+\.\d+\.\d+/); assert.match(pkg.license, /Apache-2\.0/, 'bundle 随根包发布,许可证沿用根包'); assert.equal(pkg.type, 'module'); const rel = pkg.dsh?.bundle?.patch; assert.equal(typeof rel, 'string', 'dsh.bundle.patch 必须存在'); const abs = join(DSH_DIR, rel); assert.ok(existsSync(abs), `dsh.bundle.patch 指向不存在的文件:${rel}`); assert.ok(abs.startsWith(DSH_DIR), 'dsh.bundle.patch 必须落在仓库内'); assert.ok(Array.isArray(pkg.files) && pkg.files.length > 0); for (const f of pkg.files) { assert.ok(existsSync(join(DSH_DIR, f)), `files 里列了未随包发布的文件:${f}`); } }); test('dsh.bundle.patch 指向的文件必须随包发布(files 里漏列即安装期失败)', () => { const pkg = JSON.parse(readFileSync(PACKAGE_FILE, 'utf8')); const rel = pkg.dsh?.bundle?.patch; assert.equal(typeof rel, 'string', 'dsh.bundle.patch 必须存在'); // 归一成包内相对路径:'./dsh.patch.yml' 与 files 里的 'dsh.patch.yml' 视为同一项 const norm = (p) => p.replace(/^\.\//, '').split('\\').join('/'); const target = norm(rel); const listed = (pkg.files ?? []).map(norm); assert.ok( listed.includes(target), `dsh.bundle.patch 指向 ${rel},但 package.json 的 files 里没有它;` + 'pnpm 的 file: 安装按 files 复制,漏列会让 dsh plugin add 报 ' + 'failed to read overlay … ENOENT 并整体回滚', ); // bundle 会被整包装进 profile 的 node_modules,不得带上运行时依赖 assert.equal(pkg.dependencies, undefined, 'bundle 包不得有运行时依赖'); }); test('根 LICENSE 是 Apache-2.0 全文,供随包发布的补丁使用', () => { const license = readFileSync(join(DSH_DIR, 'LICENSE'), 'utf8'); assert.ok(license.includes('Apache License'), 'LICENSE 应含 Apache License 标题行'); assert.ok(license.includes('Version 2.0, January 2004'), 'LICENSE 应是 Apache-2.0 全文'); assert.ok(license.includes('END OF TERMS AND CONDITIONS'), 'LICENSE 应是全文而非节选'); }); test('补丁里只有一行 preset 声明,身份与官方内置 preset 不冲突', () => { assert.ok(PRESET_ROW.value.has('id'), 'preset 行应有 Loader 寻址用的 id'); assert.equal(nodeValue(PRESET_ROW.value.get('name')), '@deepseek-ai/dsh-agent-preset'); const cfg = PRESET_CONFIG; assert.ok(cfg instanceof Map, 'preset 行的 config 应是映射'); const id = nodeValue(cfg.get('id')); assert.equal(id, 'rev-skills'); assert.ok(!SHIPPED_PRESET_IDS.includes(id), `config.id 与官方内置 preset 冲突:${id}`); assert.ok(!SHIPPED_PRESET_IDS.includes(nodeValue(PRESET_ROW.value.get('id'))), 'Loader 行 id 不应复用官方值'); const order = nodeValue(cfg.get('order')); assert.ok(Number.isInteger(order) && order > 0, 'order 应是正整数'); for (const key of ['name', 'description']) { const v = nodeValue(cfg.get(key)); assert.ok(typeof v === 'string' && v.trim().length > 0, `config.${key} 应非空`); } }); test('补丁引用的子插件包名都在白名单内', () => { const plugins = allPlugins(); assert.ok(plugins.length > 10, `补丁应插入完整插件清单,实际 ${plugins.length} 项`); const unknown = plugins.filter((p) => !ALLOWED_PACKAGES.has(p.name)).map((p) => `${p.id} -> ${p.name}`); assert.deepEqual(unknown, [], `出现白名单外的包名(疑似拼写错误):\n${unknown.join('\n')}`); const ids = plugins.map((p) => p.id); assert.ok(ids.every((id) => typeof id === 'string' && id.length > 0), '每个子插件行都应有 id'); assert.equal(new Set(ids).size, ids.length, `行 id 重复:${ids.join(', ')}`); }); test('skill-filesystem 行不继承默认技能根、只声明本库技能根,并支持 REV_SKILLS_DIR 覆盖', () => { const sf = findPlugin('@deepseek-ai/dsh-skill-filesystem'); const cfg = nodeMap(sf[0].configNode); assert.ok(cfg instanceof Map, 'skill-filesystem 行应有 config'); assert.equal( nodeValue(cfg.get('includeDefaultRoots')), false, '必须显式关闭默认技能根:该提供方只贡献 customSkillDirs 这一层,不继承项目与用户技能根', ); const dirs = nodeSeq(cfg.get('customSkillDirs')); assert.ok(Array.isArray(dirs) && dirs.length === 1, 'customSkillDirs 应只有一项(本库技能根)'); const expr = skillRootExpr(); assert.ok(typeof expr === 'string' && expr.trim().length > 0, '技能根应是 !!js 求值表达式'); assert.ok(expr.includes('REV_SKILLS_DIR'), '表达式应支持 REV_SKILLS_DIR 覆盖'); assert.ok(expr.includes('DSH_HOME'), '表达式应回落到 DSH 用户技能根(DSH_HOME)'); assert.ok(expr.includes('node:os'), 'DSH 用户根应由 node:os 推出(os.homedir)'); assert.ok(expr.includes('rev-skills/package.json'), '表达式应支持从本包(rev-skills 即 bundle)解析'); assert.ok( /(\.claude['"]?\s*,\s*['"]skills|\.claude\/skills)/.test(expr), '.claude 与 skills 应拼成同一个技能根', ); assert.ok(expr.includes('existsSync'), '候选目录应用 fs.existsSync 校验存在性'); assert.ok(expr.includes('readdirSync'), '候选目录应用目录列举校验(至少含一个 re- 条目)'); assert.ok( ['node:path', 'node:fs', 'node:module'].every((m) => expr.includes(m)), '表达式应通过 process.getBuiltinModule 取 node:path / node:fs / node:module', ); for (const skill of ['re-analyze', 're-kernel', 're-game']) { assert.ok(!expr.includes(skill), '技能根表达式不得硬编码技能名'); } }); test('技能根分辨率按「环境变量 -> DSH 用户根 -> 包解析」优先级回落', () => { const expr = skillRootExpr(); // fileURLToPath 会保留末尾分隔符(`.../skills/`),resolve 归一化后再比较 const repoSkills = resolve(fileURLToPath(new URL('../.claude/skills/', import.meta.url))); const repoRoot = resolve(fileURLToPath(new URL('..', import.meta.url))); // 1) REV_SKILLS_DIR 为仓库根 -> 展开成 /.claude/skills const viaEnvRoot = evalExpr(expr, PACKAGE_FILE, { revSkillsDir: repoRoot, dshHome: '' }); assert.equal(viaEnvRoot, repoSkills); // 2) REV_SKILLS_DIR 直接指向技能根 -> 原样使用 const viaEnvDir = evalExpr(expr, PACKAGE_FILE, { revSkillsDir: repoSkills, dshHome: '' }); assert.equal(viaEnvDir, repoSkills); // 3) 无环境变量且包未安装 -> 回落到 /skills const home = join(tmpdir(), 'dsh-preset-test-home'); const homeSkills = join(home, 'skills'); mkdirSync(join(homeSkills, 're-test'), { recursive: true }); writeFileSync(join(homeSkills, 're-test', 'SKILL.md'), 'x'); try { const viaHome = evalExpr(expr, PACKAGE_FILE, { revSkillsDir: '', dshHome: home }); assert.equal(viaHome, homeSkills); } finally { rmSync(home, { recursive: true, force: true }); } // 4) 包已安装且 DSH 用户根为空 -> 从包的 package.json 反解 skills 目录 const app = mkdtempSync(join(tmpdir(), 'dsh-preset-app-')); const fakeRepo = join(app, 'node_modules', 'rev-skills'); const fakeKeys = join(fakeRepo, '.claude', 'skills', 're-fake'); mkdirSync(fakeKeys, { recursive: true }); writeFileSync(join(fakeRepo, 'package.json'), '{}'); writeFileSync(join(fakeKeys, 'SKILL.md'), 'x'); try { const baseUrl = join(fakeRepo, 'package.json'); // DSH_HOME 指向一个不存在的目录,确保回落到包解析候选 const viaPkg = evalExpr(expr, baseUrl, { revSkillsDir: '', dshHome: join(app, 'empty-dsh-home') }); assert.equal(viaPkg, join(fakeRepo, '.claude', 'skills')); } finally { rmSync(app, { recursive: true, force: true }); } }); test('补丁文件卫生:无 tab、逐行 LF、值里无未引号的冒号空格', () => { const raw = readFileSync(PATCH_FILE, 'utf8'); assert.ok(!raw.includes('\t'), '补丁不得含 tab 字符'); assert.ok(!raw.includes('\r'), '补丁必须是 LF 行尾(不得出现 CR)'); assert.ok(raw.endsWith('\n'), '补丁应以换行结束'); // 未加引号的 `key: value` 行里,值本身不得含 ": "(会被 YAML 误解析为嵌套映射) const bad = raw .split('\n') .filter((l) => l.includes(': ')) .filter((l) => { const rest = l.slice(l.indexOf(': ') + 2); return !/^[>|][+-]?$/.test(rest.trim()) && rest.includes(': '); }); assert.deepEqual(bad, [], `这些行的标量值里含 ": ",需加引号:\n${bad.join('\n')}`); }); test('补丁与技能清单解耦,且不写文件', () => { const raw = PATCH_FILE && PATCH_TEXT; // 技能名硬编码检查:排除包名与 frontmatter 语义下的 re- 前缀出现在 YAML 值里的情况 const names = raw.split('\n').filter((l) => /(^|[^a-zA-Z0-9-])re-[a-z0-9-]+/.test(l) && !l.includes('@deepseek-ai/')); assert.deepEqual(names, [], `补丁不得硬编码技能名(预设需与技能数解耦):\n${names.join('\n')}`); for (const re of [/writeFile\w*/, /appendFile\w*/, /\bmkdir\w*/, /\bunlink\w*/, /\brmSync\b/, /\bcreateWriteStream\b/]) { assert.ok(!re.test(raw), `补丁不得写文件,命中 ${re}`); } const repoSkills = fileURLToPath(new URL('../.claude/skills/', import.meta.url)); assert.ok(existsSync(repoSkills), '技能根必须真实存在,否则预设挂载空目录'); const entries = readdirSync(repoSkills).filter((e) => e.startsWith('re-')); assert.ok(entries.length > 0, '.claude/skills 下应有 re- 技能目录'); assert.ok(entries.includes('re-analyze'), '入口技能 re-analyze 应存在'); });