Files
reverse-skills-max/vendor-new/rev-skills/tests/dsh-preset.test.mjs

388 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 为仓库根 -> 展开成 <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) 无环境变量且包未安装 -> 回落到 <DSH_HOME>/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 应存在');
});