reverse-skills-max: initial public release (restored, redacted, hardened)
This commit is contained in:
0
vendor-new/rev-skills/tests/.gitkeep
Normal file
0
vendor-new/rev-skills/tests/.gitkeep
Normal file
602
vendor-new/rev-skills/tests/audit-regressions.test.mjs
Normal file
602
vendor-new/rev-skills/tests/audit-regressions.test.mjs
Normal file
@@ -0,0 +1,602 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
|
||||
// 审查记录的回归断言:把「已修的事实性缺陷」变成永久检查。
|
||||
// 价值有两层——① 复核(确认修正确实落地)② 防回归(日后有人重写技能时,不会把错的说法写回去)。
|
||||
// 每条对应 docs/audit/2026-09-13-factual-audit.md 的一个编号。
|
||||
const S = '.claude/skills/';
|
||||
const read = (p) => readFileSync(S + p, 'utf8');
|
||||
|
||||
// 「提到错误写法」与「把错误写法当对的用」是两回事——技能里的迁移说明会刻意写出旧写法
|
||||
// (如"旧写法 Module.findExportByName(null, n) 已移除")。因此断言只认**非否定语境**下的出现。
|
||||
const NEGATION = /已移除|已废弃|弃用|无此|没有|不再|不是|勿用|不应|错误|旧写法|过时|不携带|不具备|不支持|不存在/;
|
||||
|
||||
/** 断言:这些串不该再以"正确写法"的身份出现 */
|
||||
function absent(t, file, needles, note) {
|
||||
const lines = read(file).split('\n');
|
||||
const hit = [];
|
||||
for (const n of needles) {
|
||||
const offenders = lines.filter((l) => l.includes(n) && !NEGATION.test(l));
|
||||
if (offenders.length) hit.push(`${n}(${offenders.length} 处,如:${offenders[0].trim().slice(0, 70)})`);
|
||||
}
|
||||
assert.deepEqual(hit, [], `${file} 仍把已修正的错误表述当作正确写法(${note})`);
|
||||
}
|
||||
/** 断言:文件里应出现这些串(修正后的表述) */
|
||||
function present(t, file, needles, note) {
|
||||
const text = read(file);
|
||||
const miss = needles.filter((n) => !text.includes(n));
|
||||
assert.deepEqual(miss, [], `${file} 缺少修正后的表述(${note}):${miss.join(' / ')}`);
|
||||
}
|
||||
|
||||
test('#1/#2 re-android-crypto:KeyInfo 的 API 用对', () => {
|
||||
const f = 're-android-crypto/SKILL.md';
|
||||
absent(null, f, ['getKeyAlias'], 'KeyStore 无此方法');
|
||||
present(null, f, ['getSecurityLevel'], 'API 31+ 判安全等级');
|
||||
});
|
||||
|
||||
test('#3 re-macos:Secure Enclave 密钥不能用导入 API', () => {
|
||||
const f = 're-macos/SKILL.md';
|
||||
present(null, f, ['SecKeyCreateRandomKey'], 'SE 密钥只能设备内生成');
|
||||
});
|
||||
|
||||
test('#4 re-ai-model:Safetensors 头长度是小端', () => {
|
||||
const f = 're-ai-model/SKILL.md';
|
||||
absent(null, f, ['大端 u64 头长度'], '字节序反了');
|
||||
present(null, f, ['<Q'], '小端解析');
|
||||
});
|
||||
|
||||
test('#5/#6/#7 re-mobile-forensics:备份索引是 Manifest.db、fileID 由路径派生、IsEncrypted 判加密', () => {
|
||||
const f = 're-mobile-forensics/SKILL.md';
|
||||
present(null, f, ['Manifest.db'], '文件索引在 SQLite');
|
||||
present(null, f, ['IsEncrypted'], '加密判定读 Manifest.plist 的该键');
|
||||
});
|
||||
|
||||
test('#8 re-riscv:QEMU virt 的 reset 向量在 MROM 0x1000', () => {
|
||||
const f = 're-riscv/SKILL.md';
|
||||
present(null, f, ['0x1000'], 'MROM 地址');
|
||||
present(null, f, ['0x80000000'], 'DRAM 基址(stub 跳转目标)');
|
||||
});
|
||||
|
||||
test('#9 re-dotnet:EntryPoint 是 union,不总是 RVA', () => {
|
||||
const f = 're-dotnet/SKILL.md';
|
||||
present(null, f, ['EntryPointToken'], 'union 写法');
|
||||
present(null, f, ['COMIMAGE_FLAGS_NATIVE_ENTRYPOINT'], '按标志位决定解释');
|
||||
});
|
||||
|
||||
test('#10 re-automotive:candump 的过滤语法与写文件是两件事', () => {
|
||||
const f = 're-automotive/SKILL.md';
|
||||
present(null, f, ['candump can0,7E0:7FF'], '过滤在接口名后的逗号位');
|
||||
absent(null, f, ['candump can0 -f 0x7E0'], '-f 是写文件,不是过滤');
|
||||
});
|
||||
|
||||
test('#11 re-forensics:gcore 产物走进程级复盘,不走 Volatility 整机路径', () => {
|
||||
const f = 're-forensics/SKILL.md';
|
||||
present(null, f, ['gcore'], '分流说明应保留');
|
||||
});
|
||||
|
||||
test('#12 re-ai-model:ONNX 起始字节不是固定 signature', () => {
|
||||
const f = 're-ai-model/SKILL.md';
|
||||
present(null, f, ['ir_version'], '首字段是 ir_version,随版本递增');
|
||||
});
|
||||
|
||||
test('#13 re-dotnet:版本串是典型默认值,不是不变量', () => {
|
||||
const f = 're-dotnet/SKILL.md';
|
||||
absent(null, f, ['产物恒定'], '不应作恒定断言');
|
||||
});
|
||||
|
||||
test('#14 re-macos:可访问性与访问控制是两个属性', () => {
|
||||
const f = 're-macos/SKILL.md';
|
||||
present(null, f, ['kSecAttrAccessControl'], '访问控制是它,不是 kSecAttrAccessible');
|
||||
});
|
||||
|
||||
test('#15 re-mem-forensics:volatility3 只要求 Python 3.8+,无 3.11 上限', () => {
|
||||
const f = 're-mem-forensics/SKILL.md';
|
||||
absent(null, f, ['Python 3.8-3.11'], '官方无该上限');
|
||||
});
|
||||
|
||||
test('#16 高熵不等于加密(ransomware 与 proto-rev 两处)', () => {
|
||||
const r = read('re-ransomware/SKILL.md');
|
||||
assert.ok(!/>7\.0 已加密/.test(r), 're-ransomware 不应把高熵当加密判定');
|
||||
const p = read('re-proto-rev/SKILL.md');
|
||||
assert.ok(!/几乎肯定加密/.test(p), 're-proto-rev 不应出现"几乎肯定加密"');
|
||||
});
|
||||
|
||||
test('#17 Frida 17:静态查找/枚举 API 已移除,须用模块实例方法', () => {
|
||||
const files = [
|
||||
're-frida/SKILL.md', 're-frida/references/frida-scripts.md',
|
||||
're-android-native/SKILL.md', 're-android-native/references/probes.md',
|
||||
're-crypto-id/SKILL.md', 're-ios-jb/SKILL.md', 're-flutter/SKILL.md',
|
||||
're-address-space/SKILL.md',
|
||||
];
|
||||
const offenders = [];
|
||||
for (const f of files) {
|
||||
if (!existsSync(S + f)) continue;
|
||||
const lines = read(f).split('\n');
|
||||
for (const bad of ['Module.findExportByName(', 'Module.getExportByName(', 'Module.findBaseAddress(']) {
|
||||
const badLines = lines.filter((l) => l.includes(bad) && !NEGATION.test(l));
|
||||
if (badLines.length) offenders.push(`${f}: ${bad}(${badLines[0].trim().slice(0, 70)})`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(offenders, [], `仍在使用 Frida 17 已移除的静态 API:\n${offenders.join('\n')}`);
|
||||
});
|
||||
|
||||
test('#18 re-frida-script-author:改返回值必须用 onLeave 的 retval.replace', () => {
|
||||
const f = 're-frida-script-author/SKILL.md';
|
||||
present(null, f, ['retval.replace'], 'onLeave 的 retval 才是改返回值的入口');
|
||||
absent(null, f, ['this.returnValue'], '`this` 上没有 returnValue');
|
||||
});
|
||||
|
||||
test('#19 re-ics:scapy Modbus 字段名是 camelCase startAddr', () => {
|
||||
const f = 're-ics/SKILL.md';
|
||||
present(null, f, ['startAddr'], 'scapy 定义的字段名');
|
||||
absent(null, f, ['start_addr='], '下划线写法在 scapy 上不成立');
|
||||
});
|
||||
|
||||
test('#20 re-angr:9.2.x 不是固定 Python 区间', () => {
|
||||
const f = 're-angr/SKILL.md';
|
||||
absent(null, f, ['支持 Python 3.8–3.11', '支持 Python 3.8-3.11'], '下限随补丁版本抬高');
|
||||
present(null, f, ['requires-python'], '以目标版本元数据为准');
|
||||
});
|
||||
|
||||
test('#21 re-ios-jb:读 C 字符串是 NativePointer 实例方法', () => {
|
||||
const f = 're-ios-jb/SKILL.md';
|
||||
absent(null, f, ['Memory.readCString'], 'Memory 下无此方法');
|
||||
});
|
||||
|
||||
test('补充 22:analysis-contract 的 ELF 魔数表述与字节序正确', () => {
|
||||
const f = 're-analyze/references/analysis-contract.md';
|
||||
const text = read(f);
|
||||
assert.ok(text.includes('7f 45 4c 46'), '应给出字节序列');
|
||||
assert.ok(text.includes('0x464c457f'), '小端 u32 读法');
|
||||
assert.ok(!/小端读作 0x7f454c46/.test(text), '旧表述把大端读法写成了小端');
|
||||
assert.ok(!text.includes('0x202'), '最初的错误值不应复现');
|
||||
});
|
||||
|
||||
test('补充 22:system-fingerprints 的 FreeBSD 标识是 .note.tag', () => {
|
||||
const f = 're-analyze/references/system-fingerprints.md';
|
||||
const text = read(f);
|
||||
assert.ok(text.includes('.note.tag'), '现代 FreeBSD 由 crtbrand.S 生成 .note.tag');
|
||||
assert.ok(!text.includes('`.note.ABI-tag`(FreeBSD)'), '不应把 note type 与 section name 混写');
|
||||
});
|
||||
|
||||
test('补充 22:ET_REL 不单独断定 Linux 内核模块', () => {
|
||||
const f = 're-analyze/references/system-fingerprints.md';
|
||||
const text = read(f);
|
||||
assert.ok(!/ET_REL\s*\*\*\s*→.*Linux 内核模块/s.test(text) || !/\[强\]\s*Linux 内核模块/.test(text),
|
||||
'ET_REL 只说明"可重定位 ELF",需组合判据才升 Linux kmod');
|
||||
});
|
||||
|
||||
test('补充 28:Mach-O 基础常量表与 Apple 头文件一致', () => {
|
||||
const f = 're-format-macho/references/layout.md';
|
||||
const text = read(f);
|
||||
// 旧的错误表述不得复现
|
||||
assert.ok(!text.includes('0x0100000B=arm64e'), 'arm64e 的 cputype 不是 0x0100000B');
|
||||
assert.ok(!text.includes('0x80000003=x86_64h'), '0x80000003 是 ALL+LIB64 位,不是 x86_64h');
|
||||
assert.ok(!/4=TWOLEVEL/.test(text), 'TWOLEVEL 是 0x80 不是 4');
|
||||
assert.ok(!/0x800000=LAZY_INIT/.test(text), '0x800000 是 HAS_TLV_DESCRIPTORS');
|
||||
assert.ok(!/0x0F=N_UNDF/.test(text), '0x0f = N_SECT|N_EXT');
|
||||
assert.ok(!/LE 文件字节 be ba fe ca/.test(text), 'fat 恒为大端,文件开头是 CA FE BA BE');
|
||||
assert.ok(!/offset 为 4 字节对齐的绝对文件偏移/.test(text), 'align 是 2 的幂,不是固定 4 字节对齐');
|
||||
// 修正后的取值应在文中
|
||||
for (const s of ['0x0100000C', 'ARM64E', '0x40', 'TWOLEVEL', '0x800000', 'N_SECT', 'CA FE BA BE', '2 的幂']) {
|
||||
assert.ok(text.includes(s), `缺少修正后的表述:${s}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('补充 28:udsoncan 示例带 config 的 data_identifiers', () => {
|
||||
const f = 're-automotive/SKILL.md';
|
||||
const text = read(f);
|
||||
assert.ok(text.includes('data_identifiers'), 'DID 解码依赖配置里的 codec 映射');
|
||||
assert.ok(text.includes('config=config'), '示例必须把 config 传给 Client');
|
||||
assert.ok(!/with Client\(conn, request_timeout=2\) as client/.test(text), '不带 config 的旧示例会解码失败');
|
||||
});
|
||||
|
||||
test('补充 27:re-flutter 的 Dart 格式 magic 是数值不是 ASCII 串', () => {
|
||||
const f = 're-flutter/SKILL.md';
|
||||
absent(null, f, ['"KERNEL" magic'], 'KERNEL 不是 magic(正确写法是数值 0x90abcdef)');
|
||||
present(null, f, ['0x90abcdef'], 'kernel magic 实为该数值');
|
||||
present(null, f, ['0xdcdcf5f5'], 'snapshot magic 实为该数值');
|
||||
absent(null, f, ["b'SNAPSHOT'"], '快照头里没有 ASCII SNAPSHOT');
|
||||
absent(null, f, ['版本串 12B'], '不存在固定 12 字节版本串');
|
||||
});
|
||||
|
||||
test('补充 27:re-flutter 的 Profile 属 AOT 一侧', () => {
|
||||
const f = 're-flutter/SKILL.md';
|
||||
absent(null, f, ['kernel_blob 只在 debug/Profile 构建存在'], 'Profile 用的是 AOT');
|
||||
present(null, f, ['Profile 与 Release 都是 AOT'], '执行模型划分');
|
||||
});
|
||||
|
||||
test('补充 27:不写死 IDA Free 的版本代际', () => {
|
||||
const f = 're-analyze/references/preferences.md';
|
||||
absent(null, f, ['当前 8.x'], '版本代际会漂移');
|
||||
});
|
||||
|
||||
test('补充 27:mitmproxy 的 Python 下限为 3.12', () => {
|
||||
const f = 're-netcap/SKILL.md';
|
||||
present(null, f, ['Python >=3.12'], '当前 12.x 的要求');
|
||||
absent(null, f, ['(Python 3.10+)'], '旧的 Python 下限');
|
||||
});
|
||||
|
||||
test('补充 27:angr 的 Python 版本矩阵只维护一处(re-angr)', () => {
|
||||
const f = 're-exploit/SKILL.md';
|
||||
absent(null, f, ['9.x 支持 Python 3.9–3.11'], '与 re-angr 冲突的旧矩阵');
|
||||
present(null, f, ['[[re-angr]]'], '指向唯一事实源');
|
||||
});
|
||||
|
||||
test('补充 32:Python 版本下限与无 pin 安装一致(torch/protobuf/web3)', () => {
|
||||
absent(null, 're-ai-model/SKILL.md', ['Python 3.9+'], 'torch 当前要求 >=3.10');
|
||||
present(null, 're-ai-model/SKILL.md', ['>=3.10'], 'torch 下限');
|
||||
absent(null, 're-ai-model/SKILL.md', ['(Python 3.8+,onnx'], 'protobuf 当前要求 >=3.10');
|
||||
absent(null, 're-blockchain/SKILL.md', ['Python 3.8+'], 'web3 8.x 要求 >=3.10');
|
||||
present(null, 're-blockchain/SKILL.md', ['7.x'], '3.8/3.9 需 pin 的出路');
|
||||
});
|
||||
|
||||
test('补充 32:angr 的 Python 矩阵只在 re-angr 维护一处', () => {
|
||||
const f = 're-crypto-decrypt/SKILL.md';
|
||||
absent(null, f, ['(Python 3.8+,依赖多'], '与 re-angr 冲突的旧副本');
|
||||
present(null, f, ['[[re-angr]]'], '指向唯一事实源');
|
||||
});
|
||||
|
||||
test('补充 32:attach 失败不得直接判成 PPL,内核调试不是唯一手段', () => {
|
||||
const f = 're-anti-cheat/SKILL.md';
|
||||
absent(null, f, ['内核调试是分析反作弊驱动的唯一动态手段', '唯一可用的动态手段',
|
||||
'反作弊驱动用 PPL(Protected Process Light)保护游戏与自身进程'], 'PPL 是 Windows 的机制,不由反作弊驱动"设定"');
|
||||
present(null, f, ['ProcessProtectionLevelInfo', 'PROTECTION_LEVEL_NONE'], '先查真实 protection level');
|
||||
present(null, f, ['ObRegisterCallbacks'], '普通进程 attach 失败的替代解释');
|
||||
});
|
||||
|
||||
test('补充 32:Zig 普通函数不是默认 C ABI', () => {
|
||||
const files = ['re-zig/SKILL.md', 're-zig/references/layout.md'];
|
||||
const bad = [];
|
||||
for (const f of files) {
|
||||
const lines = read(f).split('\n');
|
||||
for (const l of lines) {
|
||||
if (/默认调用约定即 C ABI|为默认.*x86-64 SysV|调用约定:默认 C ABI/.test(l) && !/只在|不是|仅/.test(l)) {
|
||||
bad.push(`${f}: ${l.trim().slice(0, 80)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepEqual(bad, [], '普通 Zig fn 用 Zig 默认约定,只有 extern/export/callconv(.c) 才是 C ABI');
|
||||
present(null, files[0], ['extern'], 'C ABI 边界的来源');
|
||||
present(null, files[1], ['callconv(.c)'], '显式要求 C 约定');
|
||||
});
|
||||
|
||||
test('补充 32:DNP3 组 3 是静态双位输入,不是事件', () => {
|
||||
const f = 're-ics/SKILL.md';
|
||||
absent(null, f, ['组 3 事件'], '组 3 = Double-bit Binary Input(静态);其事件对象是组 4');
|
||||
present(null, f, ['Double-bit Binary Input'], '正确的对象名');
|
||||
present(null, f, ['组 4'], '配对的静态/事件关系');
|
||||
});
|
||||
|
||||
test('补充 32:AArch32 的 8 项向量表不得泛化到整个 Cortex-A', () => {
|
||||
const f = 're-arm/SKILL.md';
|
||||
const lines = read(f).split('\n');
|
||||
const bad = lines.filter((l) => l.includes('异常向量表 8 项') && !/AArch32|Armv7/.test(l));
|
||||
assert.deepEqual(bad, [], 'AArch64 用 VBAR_ELx + 16×0x80,不存在固定 8 项表');
|
||||
present(null, f, ['AArch32', 'AArch64'], '两套异常模型必须分开写(缺一侧则等于把结论删掉而非改正)');
|
||||
present(null, f, ['VBAR_EL', '0x80'], 'AArch64 向量表的基址寄存器与槽位步长');
|
||||
present(null, f, ['vbar_el1'], '定位异常表的线索(MSR VBAR_ELx 序列)');
|
||||
});
|
||||
|
||||
test('补充 31:RISC-V Float ABI 以 e_flags 判定,不用浮点指令反推', () => {
|
||||
const f = 're-riscv/SKILL.md';
|
||||
absent(null, f, ['混编软/硬浮点编译单元时按单元确认'], 'linker 会直接拒绝不同 Float ABI 的输入');
|
||||
present(null, f, ['EF_RISCV_FLOAT_ABI'], '以 e_flags 掩码判定');
|
||||
present(null, f, ['cannot link object files with different floating-point ABI'], '链接期实际行为');
|
||||
present(null, f, ['mabi=lp64'], 'ISA 扩展与调用约定是两件事的反例');
|
||||
});
|
||||
|
||||
test('补充 31:re-arm 不得用浮点指令频率佐证 float ABI(同型缺陷)', () => {
|
||||
const f = 're-arm/SKILL.md';
|
||||
absent(null, f, ['vcmp/vcvt 等浮点指令出现频率佐证'], 'softfp 即反例:有 VFP 指令但参数仍走 r0-r3');
|
||||
present(null, f, ['softfp'], 'softfp 的定位必须写明');
|
||||
present(null, f, ['浮点指令出现频率不能佐证'], '修正后的表述');
|
||||
});
|
||||
|
||||
test('补充 31:pwntools 的 Python 兼容矩阵只维护一处', () => {
|
||||
const e = 're-exploit/SKILL.md';
|
||||
absent(null, e, ['Python 3.8+'], '与 re-pwn 冲突的旧表述');
|
||||
present(null, e, ['[[re-pwn]]'], '指向唯一事实源');
|
||||
const p = 're-pwn/SKILL.md';
|
||||
present(null, p, ['pwntools==4.15.0'], '旧环境需 pin');
|
||||
present(null, p, ['最后一个支持'], '迁移窗口说明');
|
||||
});
|
||||
|
||||
test('补充 30:PT_LOAD 不得表述成"唯一被映射的段类型"', () => {
|
||||
const f = 're-format-elf/references/layout.md';
|
||||
const lines = read(f).split('\n');
|
||||
const offenders = lines.filter((l) => l.includes('唯一被映射') && !/不要|不是|不能|而非/.test(l));
|
||||
assert.deepEqual(offenders, [], 'ELF gABI 只规定 PT_LOAD 是 loadable segment;PT_PHDR 同样描述内存映像中的位置');
|
||||
present(null, f, ['loadable segment'], '应保留 loadable segment 的定位');
|
||||
present(null, f, ['PT_PHDR'], '反证应写明');
|
||||
});
|
||||
|
||||
test('补充 30:PT_GNU_STACK 缺失不得一律判定为可执行栈', () => {
|
||||
const f = 're-format-elf/references/layout.md';
|
||||
const lines = read(f).split('\n');
|
||||
const offenders = lines.filter(
|
||||
(l) => /缺失.*假定可执行|缺失\s*=\s*可执行栈|缺失.*= *可执行/.test(l) && !/不能|依目标|而异|不可/.test(l)
|
||||
);
|
||||
assert.deepEqual(offenders, [], '缺失 PT_GNU_STACK 的默认策略依目标 ABI/内核而异,不存在统一规则');
|
||||
present(null, f, ['存在时'], '存在与缺失两种情形必须分开写');
|
||||
present(null, f, ['READ_IMPLIES_EXEC', 'x86-64', 'ia32'], '跨架构差异(ia32 缺失才走 RIE)');
|
||||
});
|
||||
|
||||
test('补充 30:udsoncan 示例保留"部分配置可用"这一已实测事实', () => {
|
||||
const f = 're-automotive/SKILL.md';
|
||||
// 这条对应一次**被驳回的报告**:报称示例里 ClientConfig() 会让 import/实例化失败,实测均不成立
|
||||
//(client.py 运行时 import 了 ClientConfig;TypedDict 实例化得空 dict;refresh_config 会补齐缺键)。
|
||||
// 断言的作用是把这个已核事实钉住,防止日后被"修"成别的说法。
|
||||
present(null, f, ['default_client_config'], 'Client 会用默认配置补齐缺键(实测 1.26.1)');
|
||||
present(null, f, ['ConfigError'], '未配该 DID 的失败签名(发送前抛出)');
|
||||
present(null, f, ['DidCodec'], 'codec 必须是 DidCodec 实例');
|
||||
});
|
||||
|
||||
test('补充 29:re-apk 的 apktool 版本模型是 3.x 主线 / 2.x 维护线', () => {
|
||||
const g = 're-apk/references/gotchas.md';
|
||||
present(null, g, ['3.x 为当前主线', '2.x 为维护线'], '版本模型');
|
||||
present(null, g, ['--all-src'], '取代 --only-main-classes 的选项');
|
||||
present(null, g, ['--aapt'], '3.x 的 aapt2 二进制入口');
|
||||
present(null, g, ['Unrecognized option'], '旧短参在 3.x 的失败表现');
|
||||
present(null, 're-apk/SKILL.md', ['3.x 为当前主线'], '工具准备段须先定 major version');
|
||||
});
|
||||
|
||||
test('补充 29:apktool 调用行里不得残留 2.x 专有选项', () => {
|
||||
// 迁移说明会**刻意引用**旧写法("--use-aapt2 已移除"),因此只查"像命令一样写出来"的行;
|
||||
// 判据是行内出现 apktool d/b 调用,且整行不在迁移语境里。
|
||||
const MIGR = /移除|消失|取代|不再|没有|无此|旧写法|过时|改用|2\.x|须写/;
|
||||
const BANNED = ['--use-aapt2', '--use-aapt1', '--api-level', '--only-main-classes'];
|
||||
const files = ['re-apk/SKILL.md', 're-apk/references/gotchas.md', 're-apk/references/commands.md'];
|
||||
const bad = [];
|
||||
for (const f of files) {
|
||||
for (const l of read(f).split('\n')) {
|
||||
if (!/\bapktool\s+[bd]\b/.test(l) || MIGR.test(l)) continue;
|
||||
for (const opt of BANNED) if (l.includes(opt)) bad.push(`${f}: ${l.trim().slice(0, 90)}`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(bad, [], `apktool 调用行里仍有 2.x 专有选项:\n${bad.join('\n')}`);
|
||||
});
|
||||
|
||||
test('补充 29:apktool 的 --api-level 不得写成可用选项', () => {
|
||||
const files = ['re-apk/SKILL.md', 're-apk/references/gotchas.md', 're-apk/references/commands.md'];
|
||||
const offenders = [];
|
||||
for (const f of files) {
|
||||
for (const l of read(f).split('\n')) {
|
||||
if (l.includes('--api-level') && !/移除|不再|消失|没有|无此|旧写法|过时/.test(l)) {
|
||||
offenders.push(`${f}: ${l.trim().slice(0, 90)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepEqual(offenders, [], '--api-level 在 apktool 3.x 已移除(API 等级不再由命令行指定)');
|
||||
});
|
||||
|
||||
test('补充 29:不再支持 32 位的表述与 2.x 的 _64 后缀命名一致', () => {
|
||||
const g = 're-apk/references/gotchas.md';
|
||||
present(null, g, ['32 位'], '3.x 无 32 位产物');
|
||||
assert.ok(!/老环境用 `aapt`(34 前默认).*apktool/.test(read(g)), 'aapt 与 apktool 的版本线不得混写');
|
||||
});
|
||||
|
||||
test('补充 23:re-angr 的 recv hook 返回值是长度不是指针', () => {
|
||||
const f = 're-angr/SKILL.md';
|
||||
present(null, f, ['ssize_t'], '返回值语义');
|
||||
absent(null, f, ['hook 返回符号指针'], '旧写法会把控制流建歪');
|
||||
});
|
||||
|
||||
// ===== 补充 34:监控报告对账波落地(2026-10-05)=====
|
||||
// 对 95 条外部报告逐条判定后采纳 70 条。下列断言锁住修正后的表述,防止日后重写时把错的说法写回;
|
||||
// 文件末尾另有「驳回项守护」——那 11 条经实测判定为报告有误,仓库原表述是对的,不得被"修"错。
|
||||
|
||||
test('补充 34:Ghidra 版本不再钉死 patch 号', () => {
|
||||
const f = 're-ghidra/SKILL.md';
|
||||
absent(null, f, ['2026-08 最新 12.1.3'], '易腐化的版本写法');
|
||||
present(null, f, ['12.x 为当前主线'], '改为以官方 Releases 为准');
|
||||
});
|
||||
|
||||
test('补充 34:angr 主线已到 10.x(不再是 9.3.x)', () => {
|
||||
const f = 're-angr/SKILL.md';
|
||||
absent(null, f, ['最新 9.3.x 线'], '9.3.x 已不是主线');
|
||||
present(null, f, ['10.x'], '当前主线');
|
||||
});
|
||||
|
||||
test('补充 34:binwalk 的 pip 渠道标为 legacy v2,v3 走官方安装方式', () => {
|
||||
const f = 're-fw-extract/SKILL.md';
|
||||
absent(null, f, ['版本新、签名库全'], '把 2015 年的 pip 包说成"版本新"');
|
||||
present(null, f, ['legacy', 'v3'], 'v2 遗留 / v3 主线要分清');
|
||||
});
|
||||
|
||||
test('补充 34:rizin 的 apt 安装不得再作为可用安装方式(副本技能)', () => {
|
||||
const files = [
|
||||
're-variant/SKILL.md', 're-patching/SKILL.md', 're-license/SKILL.md',
|
||||
're-imports/SKILL.md', 're-deobfuscate/SKILL.md', 're-unpack-advanced/SKILL.md',
|
||||
];
|
||||
// 提醒「apt 装不上」是允许的写法,只禁把 apt 当作可用安装方式
|
||||
const offenders = [];
|
||||
for (const f of files) {
|
||||
for (const l of read(f).split('\n')) {
|
||||
if (l.includes('apt install rizin') && !/会失败|无 rizin|无包|不再|不是|旧写法|勿用/.test(l)) {
|
||||
offenders.push(`${f}: ${l.trim().slice(0, 90)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepEqual(offenders, [], 'Debian/Ubuntu 仓库无 rizin 包,apt 装不上');
|
||||
present(null, files[0], ['re-radare2'], '安装矩阵应指向单一事实源');
|
||||
});
|
||||
|
||||
test('补充 34:QEMU 默认网络是 user 后端不是断网(安全基线)', () => {
|
||||
const files = ['re-fw-emulate/SKILL.md', 're-fw-emulate/references/commands.md', 're-fw-emulate/references/gotchas.md'];
|
||||
for (const f of files) absent(null, f, ['默认 `-net none`', '默认无网络'], 'QEMU 不加参数会自建 NIC + user 后端');
|
||||
present(null, files[1], ['-nic none'], '不可信固件必须显式断网');
|
||||
});
|
||||
|
||||
test('补充 34:IDA 9.x 无 64 后缀(无头统一 idat)', () => {
|
||||
const f = 're-ida/SKILL.md';
|
||||
absent(null, f, ['idat64(64 位无头)'], '9.x 已取消 64 后缀二进制');
|
||||
present(null, f, ['idat'], '以 idat 为基线');
|
||||
});
|
||||
|
||||
test('补充 34:JNIEnv 槽位是固定 ABI(RegisterNatives = 215)', () => {
|
||||
const f = 're-android-native/SKILL.md';
|
||||
absent(null, f, ['槽号是易变参数'], '槽位是 JNI ABI 固定布局');
|
||||
present(null, f, ['215'], 'RegisterNatives 的规范 index');
|
||||
present(null, 're-android-native/references/probes.md', ['215'], '探测层也不得再写"槽号随头文件漂移"');
|
||||
});
|
||||
|
||||
test('补充 34:eBPF helper 号是 UAPI 稳定编号,不"漂移"', () => {
|
||||
const f = 're-ebpf/SKILL.md';
|
||||
absent(null, f, ['随内核版本增删漂移'], '已有 ID 不随内核重编号');
|
||||
present(null, f, ['稳定'], '编号稳定性');
|
||||
});
|
||||
|
||||
test('补充 34:Delphi 经典 VMT 负偏移(vmt-0x20 一类写法已纠正)', () => {
|
||||
const f = 're-format-pe/SKILL.md';
|
||||
absent(null, f, ['vmt-0x20'], '原值落进了 TObject 虚方法槽区');
|
||||
present(null, f, ['-0x3C'], '经典 Win32 的 TypeInfo 偏移');
|
||||
});
|
||||
|
||||
test('补充 34:Itanium typeinfo 布局按指针宽度,不写死 64 位', () => {
|
||||
const f = 're-cpp-abi/SKILL.md';
|
||||
absent(null, f, ['首 8 字节 vptr'], '32 位下为 +0/+4');
|
||||
present(null, f, ['pointer-sized'], 'vptr 后接一个指针宽度的槽');
|
||||
});
|
||||
|
||||
test('补充 34:lambda closure type 不是"无 RTTI"', () => {
|
||||
const f = 're-cpp-abi/SKILL.md';
|
||||
absent(null, f, ['lambda 无 RTTI'], '缺 _ZTI 既不能排除也不能反推');
|
||||
present(null, f, ['_ZTI'], '按 mangling/捕获布局识别');
|
||||
});
|
||||
|
||||
test('补充 34:DT_SYMENT 不硬编码 24(ELF32 为 16)', () => {
|
||||
const f = 're-format-elf/SKILL.md';
|
||||
absent(null, f, ['DT_SYMENT`(24 字节/条)'], '只对 ELF64 成立');
|
||||
present(null, f, ['ELF32'], '按目标 class 取步长');
|
||||
});
|
||||
|
||||
test('补充 34:.fini_array 在退出阶段执行,不与 .init_array 同时机', () => {
|
||||
const f = 're-format-elf/SKILL.md';
|
||||
absent(null, f, ['main 之前执行)'], 'fini_array 在 main 之后');
|
||||
present(null, f, ['退出'], '退出清理阶段');
|
||||
});
|
||||
|
||||
test('补充 34:缺 PT_GNU_STACK 的 workaround 是 execstack=2', () => {
|
||||
const f = 're-format-elf/SKILL.md';
|
||||
absent(null, f, ['rtld.execstack=1'], '=1 实测无效');
|
||||
present(null, f, ['execstack=2'], '实测有效的取值');
|
||||
});
|
||||
|
||||
test('补充 34:Mach-O 的 LC 最小长度是 8 字节不是 16', () => {
|
||||
const f = 're-format-macho/SKILL.md';
|
||||
absent(null, f, ['64 位下 LC 最小 16 字节'], 'load_command 头本身即 8 字节');
|
||||
present(null, f, ['至少 8 字节'], '以结构体最小尺寸为准');
|
||||
});
|
||||
|
||||
test('补充 34:LC_DYLD_INFO 是五组信息流(含 weak_bind)', () => {
|
||||
const f = 're-format-macho/SKILL.md';
|
||||
absent(null, f, ['四张表'], '漏了 weak_bind');
|
||||
present(null, f, ['weak_bind'], '弱符号合并有独立语义');
|
||||
});
|
||||
|
||||
test('补充 34:PIE 判定要看 DF_1_PIE,不能由 ET_DYN 直接推', () => {
|
||||
const f = 're-triage/SKILL.md';
|
||||
absent(null, f, ['ET_DYN=PIE'], '共享库同样是 ET_DYN');
|
||||
present(null, f, ['DF_1_PIE'], 'readelf -d 的 FLAGS_1: PIE');
|
||||
});
|
||||
|
||||
test('补充 34:TLS 1.3 的 ALPN 结果在 EncryptedExtensions', () => {
|
||||
const f = 're-tls/SKILL.md';
|
||||
present(null, f, ['EncryptedExtensions'], 'TLS 1.3 的落点');
|
||||
});
|
||||
|
||||
test('补充 34:私有协议恢复必须先做 TCP 流重组', () => {
|
||||
const f = 're-proto-rev/SKILL.md';
|
||||
present(null, f, ['流重建'], 'segment payload ≠ 应用层 message');
|
||||
});
|
||||
|
||||
test('补充 34:密文长度不能区分分组/流密码', () => {
|
||||
const f = 're-crypto-id/SKILL.md';
|
||||
absent(null, f, ['密文长度是块大小整数倍'], 'CTR/OFB 可产出与明文等长密文');
|
||||
present(null, f, ['等长'], '长度只辅助判断 mode');
|
||||
});
|
||||
|
||||
test('补充 34:zipalign 与签名方案的顺序关系', () => {
|
||||
const f = 're-apk/references/gotchas.md';
|
||||
absent(null, f, ['v1 签名需要 zipalign'], '用 apksigner 时必须先对齐后签名');
|
||||
present(null, f, ['zipalign'], '把顺序要求写清');
|
||||
});
|
||||
|
||||
test('补充 34:Armv6-M 有合法的 32 位编码(BL 不是幻觉)', () => {
|
||||
const f = 're-arm/SKILL.md';
|
||||
absent(null, f, ['只有 16 位 Thumb'], 'Armv6-M 含 32 位 BL/DMB/DSB/ISB/MRS/MSR');
|
||||
present(null, f, ['Armv6-M'], '按具体 opcode 判定变体是否选错');
|
||||
});
|
||||
|
||||
test('补充 34:MIPS 延迟槽要区分 Branch Likely 的 annul', () => {
|
||||
const f = 're-mips/SKILL.md';
|
||||
absent(null, f, ['无条件执行'], 'likely 分支 not-taken 时延迟槽被取消');
|
||||
present(null, f, ['Branch Likely'], 'annul 语义');
|
||||
});
|
||||
|
||||
test('补充 34:Game Boy 用 SM83,不是 Z80 变体', () => {
|
||||
const f = 're-console/SKILL.md';
|
||||
absent(null, f, ['Z80 变体'], 'SM83 与 Z80 不兼容');
|
||||
present(null, f, ['SM83'], '专用 processor definition');
|
||||
});
|
||||
|
||||
test('补充 34:unc0ver 的 iOS 版本范围', () => {
|
||||
const f = 're-ios/references/gotchas.md';
|
||||
absent(null, f, ['unc0ver(iOS 14-15 系)'], 'unc0ver 不支持 iOS 15');
|
||||
present(null, f, ['11.0-14.8'], '实际支持范围');
|
||||
});
|
||||
|
||||
test('补充 34:Frida host 与 frida-server 只需 major 一致', () => {
|
||||
const f = 're-frida/SKILL.md';
|
||||
absent(null, f, ['版本必须与主机 frida 完全一致'], '官方判据是 major 匹配 + 功能存在');
|
||||
present(null, f, ['major'], '兼容判据');
|
||||
});
|
||||
|
||||
test('补充 34:运行时基线不再是 EOL 的 Node 18', () => {
|
||||
const pkg = JSON.parse(readFileSync('package.json', 'utf8'));
|
||||
assert.ok(pkg.engines.node !== '>=18', 'engines.node 不得停留在已 EOL 的 18');
|
||||
present(null, 're-crash-triage/SKILL.md', ['coredumpctl'], 'core 取回补全');
|
||||
});
|
||||
|
||||
// ===== 驳回项守护(2026-10-05)=====
|
||||
// 这 6 组经实测判定为「报告有误」,仓库原表述正确。照报告去"修"会把正确内容改错,
|
||||
// 因此反向锁住:正确表述必须在位。(依据见 docs/audit/2026-09-30-adjudication.md 第四节)
|
||||
|
||||
test('驳回守护:ET_DYN 的 e_entry 措辞不得被改成"文件偏移"', () => {
|
||||
const f = 're-format-elf/references/layout.md';
|
||||
present(null, f, ['相对基址'], 'gABI ch5 的 base address 定义 + 内核 +load_bias 支持此措辞');
|
||||
absent(null, f, ['e_entry 是文件偏移'], 'e_entry 从来不是文件偏移');
|
||||
});
|
||||
|
||||
test('驳回守护:LC_MAIN 入口公式不得被改', () => {
|
||||
const f = 're-format-macho/SKILL.md';
|
||||
present(null, f, ['vmaddr'], 'Apple loader.h 与 dyld 源码均按 vmaddr + entryoff 计算');
|
||||
});
|
||||
|
||||
test('驳回守护:x86-64 SysV 栈对齐表述不得被改', () => {
|
||||
const f = 're-exploit/SKILL.md';
|
||||
present(null, f, ['% 16 == 0'], 'psABI 原文即"call 指令执行前 16 字节对齐"');
|
||||
});
|
||||
|
||||
test('驳回守护:pwntools 4.15.0 仍是当前正式版', () => {
|
||||
present(null, 're-pwn/SKILL.md', ['4.15.0'], 'PyPI 至今无 5.x 发行版');
|
||||
});
|
||||
|
||||
test('驳回守护:capstone 5.x 仍是稳定版', () => {
|
||||
present(null, 're-emulation/references/gotchas.md', ['5.0'], '6.0.0 仅有预发行');
|
||||
});
|
||||
|
||||
test('驳回守护:udsoncan 的 ClientConfig() 示例可运行,不得照报告改', () => {
|
||||
present(null, 're-automotive/SKILL.md', ['ClientConfig'], 'TypedDict 实例化返回空 dict,refresh_config 补齐缺键');
|
||||
});
|
||||
|
||||
test('补充 34:AI 模型格式信号以字节为准(实测 file(1) 输出)', () => {
|
||||
const f = 're-ai-triage/SKILL.md';
|
||||
// 实测:onnx/safetensors/tflite 三种样本 file(1) 都报 `data`;老式 pickle 被误报为 XENIX
|
||||
absent(null, f, ['`08 08`'], 'onnx 第二字节随 ir_version 递增,不是常量');
|
||||
present(null, f, ['TFL3'], 'tflite 的文件标识符在字节 4–7');
|
||||
present(null, f, ['小端头长度'], 'safetensors 先 8 字节长度再 JSON');
|
||||
present(null, f, ['XENIX'], '老式 pickle 的 file(1) 误报可作为识别线索');
|
||||
});
|
||||
120
vendor-new/rev-skills/tests/capability.test.mjs
Normal file
120
vendor-new/rev-skills/tests/capability.test.mjs
Normal file
@@ -0,0 +1,120 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { checkCapabilityRegistry, checkRoutingCapabilities, checkCapabilityAnnotations } from '../validate.mjs';
|
||||
import {
|
||||
collectRegistryTags, collectDeclaredCapabilities,
|
||||
renderCapabilityIndex, checkCapabilityIndex, INDEX_REL,
|
||||
} from '../lib/capability-index.mjs';
|
||||
|
||||
// 造一个最小技能库:注册表两个标签,其中只有 alpha 被技能声明
|
||||
function fakeRoot({ declareBeta = false } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'cap-'));
|
||||
mkdirSync(join(root, 're-analyze', 'references'), { recursive: true });
|
||||
writeFileSync(join(root, 're-analyze', 'references', 'capabilities.md'),
|
||||
'# 能力注册表\n\n- `alpha` — 甲能力\n- `beta` — 乙能力\n');
|
||||
mkdirSync(join(root, 're-x'), { recursive: true });
|
||||
writeFileSync(join(root, 're-x', 'SKILL.md'),
|
||||
`---\nname: re-x\ndescription: 甲能力技能。capability skill.\ncapabilities: [alpha${declareBeta ? ', beta' : ''}]\n---\n\n# 标题\n\n## 工具准备\n\n正文\n`);
|
||||
return root;
|
||||
}
|
||||
|
||||
test('collectRegistryTags 解析标签与说明', () => {
|
||||
const root = fakeRoot();
|
||||
try {
|
||||
const tags = collectRegistryTags(root);
|
||||
assert.equal(tags.size, 2);
|
||||
assert.equal(tags.get('alpha'), '甲能力');
|
||||
} finally { rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('collectDeclaredCapabilities 反查标签 → 技能', () => {
|
||||
const root = fakeRoot();
|
||||
try {
|
||||
const byTag = collectDeclaredCapabilities(root);
|
||||
assert.deepEqual([...byTag.keys()], ['alpha']);
|
||||
assert.deepEqual(byTag.get('alpha'), ['re-x']);
|
||||
} finally { rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('悬空标签报错,补齐声明后消失', () => {
|
||||
const root = fakeRoot();
|
||||
try {
|
||||
const errors = checkCapabilityRegistry(root);
|
||||
assert.ok(errors.some(e => e.includes('beta') && e.includes('dangling')), '应报 beta 悬空');
|
||||
assert.ok(!errors.some(e => e.includes('alpha')), '已声明的标签不应报错');
|
||||
// 补声明后不再悬空
|
||||
writeFileSync(join(root, 're-x', 'SKILL.md'),
|
||||
`---\nname: re-x\ndescription: 甲能力技能。capability skill.\ncapabilities: [alpha, beta]\n---\n\n# 标题\n\n## 工具准备\n\n正文\n`);
|
||||
assert.deepEqual(checkCapabilityRegistry(root), []);
|
||||
} finally { rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('索引缺失 / 过期 / 同步三种状态', () => {
|
||||
const root = fakeRoot();
|
||||
try {
|
||||
// 缺失
|
||||
assert.ok(checkCapabilityIndex(root)[0].includes('missing'));
|
||||
// 生成后同步
|
||||
const content = renderCapabilityIndex(root);
|
||||
mkdirSync(join(root, 're-analyze', 'references'), { recursive: true });
|
||||
writeFileSync(join(root, INDEX_REL), content);
|
||||
assert.deepEqual(checkCapabilityIndex(root), []);
|
||||
// 声明变更后未重生成 → 过期
|
||||
writeFileSync(join(root, 're-x', 'SKILL.md'),
|
||||
`---\nname: re-x\ndescription: 甲能力技能。capability skill.\ncapabilities: [alpha, beta]\n---\n\n# 标题\n\n## 工具准备\n\n正文\n`);
|
||||
assert.ok(checkCapabilityIndex(root)[0].includes('out of date'));
|
||||
} finally { rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('路由能力列与技能声明不一致时报错', () => {
|
||||
const root = fakeRoot();
|
||||
const routing = (cap) =>
|
||||
`| 证据 | 需要能力 | 触发技能 |\n|---|---|---|\n| 看到特征 | \`${cap}\` | re-x |\n`;
|
||||
try {
|
||||
mkdirSync(join(root, 're-analyze', 'references'), { recursive: true });
|
||||
// 一致:alpha 由 re-x 声明
|
||||
writeFileSync(join(root, 're-analyze', 'references', 'rerouting.md'), routing('alpha'));
|
||||
assert.deepEqual(checkRoutingCapabilities(root), []);
|
||||
// 声明缺失:beta 无人声明
|
||||
writeFileSync(join(root, 're-analyze', 'references', 'rerouting.md'), routing('beta'));
|
||||
assert.ok(checkRoutingCapabilities(root).some(e => e.includes('beta') && e.includes('未声明')));
|
||||
// 未注册的标签
|
||||
writeFileSync(join(root, 're-analyze', 'references', 'rerouting.md'), routing('nope'));
|
||||
assert.ok(checkRoutingCapabilities(root).some(e => e.includes('未注册')));
|
||||
} finally { rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('选择树缺能力标注 / 标注与声明不符时报错', () => {
|
||||
const root = fakeRoot();
|
||||
const withTree = (branch) =>
|
||||
`---\nname: re-x\ndescription: 甲能力技能。capability skill.\ncapabilities: [alpha]\n---\n\n# 标题\n\n## 工具准备\n\n正文\n\n## 何时用哪个原子技能(选择树)\n\n${branch}\n`;
|
||||
const p = join(root, 're-x', 'SKILL.md');
|
||||
try {
|
||||
// 合规:显式「能力:」前缀且与目标技能声明一致
|
||||
writeFileSync(p, withTree('- 分支 → [[re-x]](能力:`alpha`)'));
|
||||
assert.deepEqual(checkCapabilityAnnotations(root), []);
|
||||
// 缺标注
|
||||
writeFileSync(p, withTree('- 分支 → [[re-x]]'));
|
||||
assert.ok(checkCapabilityAnnotations(root).some(e => e.includes('缺少能力标注')));
|
||||
// 标注了目标技能未声明(也未注册)的能力
|
||||
writeFileSync(p, withTree('- 分支 → [[re-x]](能力:`beta`)'));
|
||||
assert.ok(checkCapabilityAnnotations(root).some(e => e.includes('beta')));
|
||||
// 非技能链接(references 文档)不要求标注
|
||||
writeFileSync(join(root, 're-analyze', 'references', 'tips.md'), '# 提示\n');
|
||||
writeFileSync(p, withTree('- 分支 → [[re-x]](能力:`alpha`)+ [[tips]]'));
|
||||
assert.deepEqual(checkCapabilityAnnotations(root), []);
|
||||
} finally { rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('索引渲染包含已声明能力与未声明清单', () => {
|
||||
const root = fakeRoot();
|
||||
try {
|
||||
const md = renderCapabilityIndex(root);
|
||||
assert.match(md, /\| `alpha` \| 甲能力 \| re-x \|/);
|
||||
assert.match(md, /## 尚未被声明(1)/);
|
||||
assert.match(md, /- beta — 乙能力/);
|
||||
} finally { rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
41
vendor-new/rev-skills/tests/convert.test.mjs
Normal file
41
vendor-new/rev-skills/tests/convert.test.mjs
Normal file
@@ -0,0 +1,41 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, readFileSync, readdirSync, existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { readSkill, convertAll } from '../bin/convert.mjs';
|
||||
|
||||
const SKILLS = join(dirname(fileURLToPath(import.meta.url)), '..', '.claude', 'skills');
|
||||
|
||||
test('readSkill 解析 frontmatter', () => {
|
||||
const s = readSkill(join(SKILLS, 're-analyze'));
|
||||
assert.equal(s.name, 're-analyze');
|
||||
assert.ok(s.description.length > 10);
|
||||
assert.match(s.body, /##/);
|
||||
});
|
||||
|
||||
test('convertAll cursor 生成 .mdc 且带 description 头', () => {
|
||||
const out = mkdtempSync(join(tmpdir(), 'rs-cursor-'));
|
||||
const files = convertAll('cursor', out);
|
||||
const names = readdirSync(out);
|
||||
assert.ok(names.includes('re-analyze.mdc'));
|
||||
assert.match(readFileSync(join(out, 're-analyze.mdc'), 'utf8'), /^---\ndescription: /);
|
||||
assert.ok(files.length >= 46);
|
||||
});
|
||||
|
||||
test('convertAll copilot 聚合为单文件', () => {
|
||||
const out = mkdtempSync(join(tmpdir(), 'rs-copilot-'));
|
||||
convertAll('copilot', out);
|
||||
const p = join(out, 'copilot-instructions.md');
|
||||
assert.ok(existsSync(p));
|
||||
const txt = readFileSync(p, 'utf8');
|
||||
assert.match(txt, /## re-analyze/);
|
||||
assert.match(txt, /## re-z3/);
|
||||
});
|
||||
|
||||
test('convertAll windsurf 每技能一文件', () => {
|
||||
const out = mkdtempSync(join(tmpdir(), 'rs-ws-'));
|
||||
convertAll('windsurf', out);
|
||||
assert.ok(existsSync(join(out, 're-triage.md')));
|
||||
});
|
||||
93
vendor-new/rev-skills/tests/counts.test.mjs
Normal file
93
vendor-new/rev-skills/tests/counts.test.mjs
Normal file
@@ -0,0 +1,93 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
||||
|
||||
// 计数散落多处,历史上漏同步过 5 处(见 CLAUDE.md「同步计数与导航」)。
|
||||
// 本测试把那份人工清单变成检查:新技能增删后,任何一处没跟上都会红。
|
||||
const SKILLS_DIR = '.claude/skills';
|
||||
|
||||
function actualCounts() {
|
||||
let atomic = 0, gateway = 0, entry = 0;
|
||||
let total = 0;
|
||||
for (const d of readdirSync(SKILLS_DIR)) {
|
||||
if (!d.startsWith('re-') || !existsSync(`${SKILLS_DIR}/${d}/SKILL.md`)) continue;
|
||||
total++;
|
||||
const m = /^type:\s*(\w+)/m.exec(readFileSync(`${SKILLS_DIR}/${d}/SKILL.md`, 'utf8'));
|
||||
const kind = m ? m[1] : 'atomic';
|
||||
if (kind === 'gateway') gateway++;
|
||||
else if (kind === 'entry') entry++;
|
||||
else atomic++;
|
||||
}
|
||||
return { total, atomic, gateway, entry };
|
||||
}
|
||||
|
||||
// file → [正则, 期望口径, 说明];正则第 1 个捕获组是要比对的数字
|
||||
const SITES = [
|
||||
['README.md', /^(\d+) 个逆向工程技能/m, 'total', '首段技能总数'],
|
||||
['README.md', /^## 技能导航((\d+))/m, 'total', '技能导航标题'],
|
||||
['README.md', /^入口 → (\d+) 大类网关 → (\d+) 原子技能/m, 'gateway+atomic', '前言'],
|
||||
['README_EN.md', /^(\d+) reverse engineering skills/m, 'total', '首段技能总数'],
|
||||
['README_EN.md', /^## Skill map \((\d+)\)/m, 'total', 'Skill map 标题'],
|
||||
['README_EN.md', /^Entry → (\d+) category gateways → (\d+) atomic skills/m, 'gateway+atomic', '前言'],
|
||||
['AGENTS.md', /^本仓库是通用逆向工程 AI 技能库((\d+) 个技能)/m, 'total', '首段技能总数'],
|
||||
['AGENTS.md', /^- 原子技能((\d+))/m, 'atomic', '原子技能行'],
|
||||
['CLAUDE.md', /^通用逆向工程 AI 技能库((\d+) 个技能)/m, 'total', '仓库性质段'],
|
||||
['CLAUDE.md', /OK: (\d+) skills validated/, 'total', '校验输出示例'],
|
||||
['CLAUDE.md', /原子技能(type: atomic,(\d+) 个)/m, 'atomic', '架构图'],
|
||||
['package.json', /"description":\s*"[^"]*?(\d+) 个技能/, 'total', 'npm 描述'],
|
||||
['.claude-plugin/marketplace.json', /"description":\s*"[^"]*?(\d+) 个技能/, 'total', '插件市场描述'],
|
||||
];
|
||||
|
||||
test('各处的技能计数与实际目录一致', () => {
|
||||
const c = actualCounts();
|
||||
const bad = [];
|
||||
for (const [file, re, kind, label] of SITES) {
|
||||
const text = readFileSync(file, 'utf8');
|
||||
const m = re.exec(text);
|
||||
if (!m) { bad.push(`${file}(${label}):找不到计数,模式可能已失效`); continue; }
|
||||
const nums = m.slice(1).map(Number);
|
||||
const want = kind === 'total' ? [c.total]
|
||||
: kind === 'atomic' ? [c.atomic]
|
||||
: [c.gateway, c.atomic];
|
||||
nums.forEach((n, i) => {
|
||||
if (n !== want[i]) bad.push(`${file}(${label}):写的是 ${n},实际 ${want[i]}`);
|
||||
});
|
||||
}
|
||||
assert.deepEqual(bad, [], `计数不同步:\n${bad.join('\n')}`);
|
||||
});
|
||||
|
||||
test('README / README_EN 的网关行引用的技能都存在', () => {
|
||||
const missing = [];
|
||||
for (const file of ['README.md', 'README_EN.md']) {
|
||||
const text = readFileSync(file, 'utf8');
|
||||
for (const m of text.matchAll(/`(re-[a-z0-9-]+)`/g)) {
|
||||
if (!existsSync(`${SKILLS_DIR}/${m[1]}/SKILL.md`)) missing.push(`${file}: ${m[1]}`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(missing, [], `引用了不存在的技能:\n${missing.join('\n')}`);
|
||||
});
|
||||
|
||||
test('每个技能都在 README 中至少出现一次', () => {
|
||||
const readme = readFileSync('README.md', 'utf8');
|
||||
const absent = readdirSync(SKILLS_DIR)
|
||||
.filter((d) => d.startsWith('re-') && existsSync(`${SKILLS_DIR}/${d}/SKILL.md`))
|
||||
.filter((d) => !readme.includes(d));
|
||||
assert.deepEqual(absent, [], `README 未提及:${absent.join(' ')}`);
|
||||
});
|
||||
|
||||
// 测试计数也曾漂移过:CLAUDE.md 写「validate + 103 项测试」,实际已到 168。
|
||||
// 技能计数有上面的 SITES 看住,测试计数此前无人看——补上同样的检查:
|
||||
// 顶层 test( 的静态计数与 `node --test` 报告的 tests 数一致,故可静态比对。
|
||||
test('CLAUDE.md 的测试计数与实际测试数一致', () => {
|
||||
let n = 0;
|
||||
for (const f of readdirSync('tests')) {
|
||||
if (!f.endsWith('.test.mjs')) continue;
|
||||
n += (readFileSync(`tests/${f}`, 'utf8').match(/^test\(/gm) || []).length;
|
||||
}
|
||||
const m = /`npm test` = validate \+ (\d+) 项测试/.exec(readFileSync('CLAUDE.md', 'utf8'));
|
||||
assert.ok(m, 'CLAUDE.md 的「检查分层」段应写明测试项数');
|
||||
assert.equal(
|
||||
Number(m[1]), n,
|
||||
`CLAUDE.md 写 ${m[1]} 项,实际 ${n} 项——增删测试后需同步该计数`,
|
||||
);
|
||||
});
|
||||
387
vendor-new/rev-skills/tests/dsh-preset.test.mjs
Normal file
387
vendor-new/rev-skills/tests/dsh-preset.test.mjs
Normal file
@@ -0,0 +1,387 @@
|
||||
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 应存在');
|
||||
});
|
||||
517
vendor-new/rev-skills/tests/dsh.test.mjs
Normal file
517
vendor-new/rev-skills/tests/dsh.test.mjs
Normal file
@@ -0,0 +1,517 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, readdirSync, existsSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
checkSkills, checkSkillFile, applyProfileEdits, removeProfileEdits, readProfileState,
|
||||
findPresetRow, dependencyMatchesBundle, resolveProfileDir, resolveLocalSkillRoot, linkSpec,
|
||||
SKILL_NAME_PATTERN, PRUNE_THRESHOLD_CHARS, BUNDLE_PACKAGE, PRESET_ID, BUNDLE_DIR,
|
||||
DEFAULT_SKILLS_ROOT, PROFILE_BACKUP,
|
||||
} from '../bin/dsh.mjs';
|
||||
|
||||
// 夹具放在 tests/fixtures-dsh/ 而不是 tests/fixtures/dsh-*/:tests/validate.test.mjs 的
|
||||
// collectSkills 断言了 tests/fixtures/ 一级目录的完整名单,往里加目录会让那条断言变红,
|
||||
// 而那个文件不在本任务的改动范围内。细节见 tests/fixtures-dsh/README.md。
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const REPO = resolve(HERE, '..');
|
||||
const CLI = join(REPO, 'bin', 'dsh.mjs');
|
||||
const FIX = join(HERE, 'fixtures-dsh');
|
||||
|
||||
function runCli(args) {
|
||||
return spawnSync(process.execPath, [CLI, ...args], { encoding: 'utf8', cwd: REPO });
|
||||
}
|
||||
|
||||
function withTempDir(prefix, fn) {
|
||||
const dir = mkdtempSync(join(tmpdir(), prefix));
|
||||
try { return fn(dir); } finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
}
|
||||
|
||||
const writeJson = (file, obj) => writeFileSync(file, `${JSON.stringify(obj, null, 2)}\n`);
|
||||
const skillFile = (name, extra = '') => `---\nname: ${name}\ndescription: 夹具技能。fixture skill.${extra}\ntype: atomic\n---\n\n## 什么时候用\n\n夹具正文。\n`;
|
||||
const bundleExists = existsSync(join(BUNDLE_DIR, 'package.json'));
|
||||
|
||||
// ---------- checkSkills:真实技能根 ----------
|
||||
|
||||
test('checkSkills:真实技能根 0 错误,超阈值告警与目录实测一致', () => {
|
||||
const r = checkSkills(DEFAULT_SKILLS_ROOT);
|
||||
assert.deepEqual(r.errors, [], `真实技能根不应有错误:${JSON.stringify(r.errors.slice(0, 3))}`);
|
||||
assert.equal(r.ok, true);
|
||||
|
||||
// 技能数与告警名单都从目录实测推导(技能数会变,阈值不会)
|
||||
const dirs = readdirSync(DEFAULT_SKILLS_ROOT, { withFileTypes: true }).filter((e) => e.isDirectory());
|
||||
assert.equal(r.total, dirs.length);
|
||||
assert.equal(r.skills.length, dirs.length);
|
||||
|
||||
const over = dirs
|
||||
.map((e) => ({ name: e.name, chars: readFileSync(join(DEFAULT_SKILLS_ROOT, e.name, 'SKILL.md'), 'utf8').length }))
|
||||
.filter((x) => x.chars > PRUNE_THRESHOLD_CHARS);
|
||||
assert.ok(over.length > 0, '本库确实存在超过 8192 字符的技能,告警路径才有覆盖');
|
||||
assert.deepEqual(r.warnings.map((w) => w.name).sort(), over.map((x) => x.name).sort());
|
||||
for (const w of r.warnings) {
|
||||
assert.equal(w.code, 'prune-threshold');
|
||||
assert.equal(w.chars, readFileSync(w.file, 'utf8').length);
|
||||
assert.ok(w.chars > PRUNE_THRESHOLD_CHARS);
|
||||
assert.ok(w.bodyChars > 0);
|
||||
}
|
||||
// 超阈值只告警:这些技能仍在 skills 列表里,且没有任何一条因此变成错误
|
||||
const worst = over.sort((a, b) => b.chars - a.chars)[0];
|
||||
assert.equal(r.skills.find((s) => s.name === worst.name).layout, 'bundle');
|
||||
assert.equal(r.errors.some((e) => e.name === worst.name), false);
|
||||
});
|
||||
|
||||
test('checkSkills:技能根不存在报 root-missing 而不是静默通过', () => {
|
||||
const r = checkSkills(join(FIX, 'no-such-root'));
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.total, 0);
|
||||
assert.deepEqual(r.errors.map((e) => e.code), ['root-missing']);
|
||||
});
|
||||
|
||||
// ---------- CRLF 回归(Windows core.autocrlf=true 的结账) ----------
|
||||
|
||||
test('checkSkillFile/checkSkills:CRLF 文件必须 0 错误', () => {
|
||||
withTempDir('dsh-crlf-', (dir) => {
|
||||
const root = join(dir, 'skills');
|
||||
mkdirSync(join(root, 're-crlf'), { recursive: true });
|
||||
const crlf = skillFile('re-crlf').replace(/\n/g, '\r\n');
|
||||
assert.ok(crlf.includes('\r\n'), '夹具必须是真正的 CRLF 字节');
|
||||
const file = join(root, 're-crlf', 'SKILL.md');
|
||||
writeFileSync(file, crlf);
|
||||
assert.deepEqual(checkSkillFile(file, 're-crlf').errors, []);
|
||||
const r = checkSkills(root);
|
||||
assert.equal(r.ok, true, `CRLF 技能根不应报错:${JSON.stringify(r.errors)}`);
|
||||
assert.equal(r.total, 1);
|
||||
|
||||
// 块标量 + CRLF:description 仍要解析出来(不能被 \r 截断成空)
|
||||
writeFileSync(file, '---\r\nname: re-crlf\r\ndescription: >\r\n CRLF 折行描述。folded description\r\n---\r\n\r\n## 正文\r\n\r\n正文\r\n');
|
||||
assert.deepEqual(checkSkillFile(file, 're-crlf').errors, []);
|
||||
assert.equal(checkSkills(root).total, 1);
|
||||
|
||||
// BOM 是真故障,仍须点名(BOM 会让首行 != "---")
|
||||
writeFileSync(file, `\uFEFF${crlf}`);
|
||||
assert.deepEqual(checkSkillFile(file, 're-crlf').errors.map((e) => e.code), ['opening-delimiter']);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- 错误类夹具 ----------
|
||||
|
||||
test('checkSkills:夹具覆盖全部错误类,合法夹具不误报', () => {
|
||||
const r = checkSkills(join(FIX, 'check-errors'));
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.total, 14);
|
||||
|
||||
const codes = [...new Set(r.errors.map((e) => e.code))].sort();
|
||||
assert.deepEqual(codes, [
|
||||
'closing-delimiter', 'description-missing', 'empty-body', 'empty-frontmatter', 'frontmatter-not-mapping',
|
||||
'invalid-invocation-value', 'legacy-invocation-key', 'missing-skill-md', 'name-invalid', 'name-mismatch',
|
||||
'name-missing', 'opening-delimiter', 'tab-in-frontmatter',
|
||||
]);
|
||||
|
||||
// 合法对照:re-ok;re-hyphen-keys 是「仓库解析器只认 \w+ 键」那条误报的回归
|
||||
for (const name of ['re-ok', 're-hyphen-keys']) {
|
||||
assert.deepEqual(r.errors.filter((e) => e.name === name), [], `${name} 是合法夹具,不应报错`);
|
||||
}
|
||||
// 目录里没有 SKILL.md:报 missing-skill-md,占位 README.md 不能被当成技能
|
||||
assert.ok(r.errors.some((e) => e.code === 'missing-skill-md' && e.name === 're-no-skill-md'));
|
||||
assert.equal(r.skills.find((s) => s.name === 're-no-skill-md').file, null);
|
||||
// 隐藏项与非 .md 文件进 skipped,不计入技能
|
||||
assert.deepEqual([...r.skipped].sort(), ['.hidden', 'notes.txt']);
|
||||
// re-tabs 夹具必须真的含 TAB,否则这条用例是空转
|
||||
assert.ok(readFileSync(join(FIX, 'check-errors', 're-tabs', 'SKILL.md'), 'utf8').includes('\t'), 're-tabs 夹具必须含真实 TAB');
|
||||
// 每条错误都带文件路径
|
||||
for (const e of r.errors) assert.ok(typeof e.file === 'string' && e.file.length > 0, `错误缺文件路径:${JSON.stringify(e)}`);
|
||||
});
|
||||
|
||||
test('checkSkills:平铺形态按文件名判名字,超阈值只告警不报错', () => {
|
||||
const flat = checkSkills(join(FIX, 'flat-root'));
|
||||
assert.equal(flat.ok, false);
|
||||
assert.deepEqual(flat.skills.map((s) => s.layout).sort(), ['flat', 'flat']);
|
||||
assert.deepEqual(flat.errors.map((e) => [e.name, e.code]), [['Not-Kebab', 'name-invalid']]);
|
||||
assert.equal(flat.errors[0].file, join(FIX, 'flat-root', 'Not-Kebab.md'));
|
||||
|
||||
const long = checkSkills(join(FIX, 'long'));
|
||||
assert.equal(long.ok, true, '超阈值不是错误');
|
||||
assert.deepEqual(long.errors, []);
|
||||
assert.deepEqual(long.warnings.map((w) => w.code), ['prune-threshold']);
|
||||
assert.equal(long.warnings[0].chars, readFileSync(join(FIX, 'long', 're-long', 'SKILL.md'), 'utf8').length);
|
||||
assert.ok(long.warnings[0].chars > PRUNE_THRESHOLD_CHARS);
|
||||
});
|
||||
|
||||
test('技能名文法与常量:kebab-case 正则与裁剪阈值', () => {
|
||||
assert.equal(SKILL_NAME_PATTERN.test('re-analyze'), true);
|
||||
assert.equal(SKILL_NAME_PATTERN.test('re-Bad_Name'), false);
|
||||
assert.equal(SKILL_NAME_PATTERN.test('re--x'), false);
|
||||
assert.equal(SKILL_NAME_PATTERN.test('re-'), false);
|
||||
assert.equal(PRUNE_THRESHOLD_CHARS, 8192);
|
||||
assert.equal(BUNDLE_PACKAGE, 'rev-skills');
|
||||
assert.equal(PRESET_ID, 'rev-skills');
|
||||
});
|
||||
|
||||
// ---------- CLI:check ----------
|
||||
|
||||
test('CLI check:通过时打印 OK 汇总行并退出 0,--json 可解析', () => {
|
||||
const text = runCli(['check']);
|
||||
assert.equal(text.status, 0, `stderr: ${text.stderr}`);
|
||||
const summary = text.stdout.split('\n').find((l) => l.startsWith('OK: '));
|
||||
assert.ok(summary, `缺少 OK 汇总行:\n${text.stdout}`);
|
||||
assert.match(summary, /^OK: \d+ skills DSH-compatible \(\d+ warning\(s\)\)$/);
|
||||
|
||||
const dirs = readdirSync(DEFAULT_SKILLS_ROOT, { withFileTypes: true }).filter((e) => e.isDirectory()).length;
|
||||
const json = runCli(['check', '--json']);
|
||||
assert.equal(json.status, 0);
|
||||
const obj = JSON.parse(json.stdout);
|
||||
assert.equal(obj.ok, true);
|
||||
assert.equal(obj.total, dirs);
|
||||
assert.deepEqual(obj.errors, []);
|
||||
assert.equal(obj.warnings.length > 0, true);
|
||||
assert.equal(obj.checks.length > 0, true, '--json 应逐条列出实际做了哪些检查');
|
||||
});
|
||||
|
||||
test('CLI check:有错时逐条打到 stderr 并退出 1', () => {
|
||||
const r = runCli(['check', '--dir', join(FIX, 'check-errors')]);
|
||||
assert.equal(r.status, 1);
|
||||
assert.match(r.stderr, /^FAIL: .+ — /m);
|
||||
assert.match(r.stderr, /FAIL: \d+ error\(s\) \/ 14 skills checked/);
|
||||
assert.ok(r.stdout.includes('skills root: '), '根路径应在 stdout 上给出');
|
||||
|
||||
const json = runCli(['check', '--dir', join(FIX, 'check-errors'), '--json']);
|
||||
assert.equal(json.status, 1);
|
||||
const obj = JSON.parse(json.stdout);
|
||||
assert.equal(obj.ok, false);
|
||||
assert.equal(obj.total, 14);
|
||||
assert.equal(obj.errors.length > 0, true);
|
||||
|
||||
const missing = runCli(['check', '--dir', join(FIX, 'no-such-root'), '--json']);
|
||||
assert.equal(missing.status, 1);
|
||||
assert.equal(JSON.parse(missing.stdout).errors[0].code, 'root-missing');
|
||||
});
|
||||
|
||||
test('CLI:未知子命令/参数打印用法到 stderr 并退出 1;--help 退出 0', () => {
|
||||
for (const args of [[], ['frobnicate'], ['preset'], ['preset', 'bogus'], ['check', '--nope']]) {
|
||||
const r = runCli(args);
|
||||
assert.equal(r.status, 1, `${JSON.stringify(args)} 应退出 1`);
|
||||
assert.equal(r.stdout.trim(), '', `${JSON.stringify(args)} 的用法不应写到 stdout`);
|
||||
assert.match(r.stderr, /用法:/, `${JSON.stringify(args)} 应打印用法`);
|
||||
}
|
||||
const help = runCli(['--help']);
|
||||
assert.equal(help.status, 0);
|
||||
assert.match(help.stdout, /用法:/);
|
||||
});
|
||||
|
||||
// ---------- CLI:preset spec ----------
|
||||
|
||||
test('CLI preset spec:打印 bundle 绝对路径、profile 编辑与等价 pnpm 命令', () => {
|
||||
const r = runCli(['preset', 'spec']);
|
||||
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
||||
assert.ok(r.stdout.includes(resolve(BUNDLE_DIR)), '应给出 bundle 绝对路径');
|
||||
assert.ok(r.stdout.includes(BUNDLE_PACKAGE));
|
||||
assert.ok(r.stdout.includes('dependencies'));
|
||||
assert.ok(r.stdout.includes(`pnpm add "${linkSpec(BUNDLE_DIR)}"`));
|
||||
assert.ok(r.stdout.includes('REV_SKILLS_DIR'), '应给出技能根覆盖结果');
|
||||
assert.ok(r.stdout.includes('.claude'), '技能根解析结果里应有仓库技能目录');
|
||||
});
|
||||
|
||||
// ---------- profile 状态与编辑(纯函数) ----------
|
||||
|
||||
test('readProfileState:合成 profile/bundle 下逐项判定一致', () => {
|
||||
withTempDir('dsh-state-', (dir) => {
|
||||
const profileDir = join(dir, 'profile');
|
||||
const bundleDir = join(dir, 'bundle');
|
||||
mkdirSync(profileDir, { recursive: true });
|
||||
mkdirSync(bundleDir, { recursive: true });
|
||||
writeFileSync(join(bundleDir, 'cordis.patch.yml'), '- insert:\n - id: preset-rev-skills\n config:\n id: rev-skills\n');
|
||||
writeFileSync(join(bundleDir, 'package.json'), JSON.stringify({ name: BUNDLE_PACKAGE, dsh: { bundle: { patch: './cordis.patch.yml' } } }));
|
||||
|
||||
// 未安装
|
||||
writeJson(join(profileDir, 'package.json'), { name: 'p', dependencies: {}, dsh: { profile: { bundles: ['@deepseek-ai/dsh-base'] } } });
|
||||
const before = readProfileState(profileDir, bundleDir);
|
||||
assert.equal(before.ok, false);
|
||||
assert.equal(before.bundleExists, true);
|
||||
assert.equal(before.presetRowFound, true);
|
||||
assert.equal(before.presetRow.line, 2);
|
||||
assert.equal(before.listedInBundles, false);
|
||||
assert.equal(before.dependencyEntry, null);
|
||||
assert.deepEqual(before.bundles, ['@deepseek-ai/dsh-base']);
|
||||
|
||||
// 已安装且一致
|
||||
const edited = applyProfileEdits(before.pkg, bundleDir);
|
||||
writeFileSync(join(profileDir, 'package.json'), `${JSON.stringify(edited.next, null, 2)}\n`);
|
||||
const after = readProfileState(profileDir, bundleDir);
|
||||
assert.equal(after.ok, true);
|
||||
assert.equal(after.listedInBundles, true);
|
||||
assert.equal(after.dependencyEntry, `link:${bundleDir.split('\\').join('/')}`);
|
||||
assert.equal(after.dependencyMatchesBundle, true);
|
||||
|
||||
// profile 不可读 / 缺 bundle
|
||||
writeFileSync(join(profileDir, 'package.json'), '{ not json');
|
||||
const broken = readProfileState(profileDir, bundleDir);
|
||||
assert.equal(broken.ok, false);
|
||||
assert.match(broken.profileError, /不是合法 JSON/);
|
||||
const noBundle = readProfileState(profileDir, join(dir, 'nope'));
|
||||
assert.equal(noBundle.bundleExists, false);
|
||||
assert.equal(noBundle.ok, false);
|
||||
assert.equal(noBundle.patchExists, false);
|
||||
});
|
||||
});
|
||||
|
||||
test('applyProfileEdits / removeProfileEdits:幂等、不改原对象、保留其它键', () => {
|
||||
const base = {
|
||||
name: 'p',
|
||||
dependencies: { other: '1.0.0' },
|
||||
dsh: { profile: { bundles: ['@deepseek-ai/dsh-base'] } },
|
||||
};
|
||||
const a1 = applyProfileEdits(base, BUNDLE_DIR);
|
||||
assert.deepEqual(a1.before, { bundles: ['@deepseek-ai/dsh-base'], dependency: null });
|
||||
assert.deepEqual(a1.after.bundles, ['@deepseek-ai/dsh-base', BUNDLE_PACKAGE]);
|
||||
assert.equal(a1.after.dependency, linkSpec(BUNDLE_DIR));
|
||||
assert.equal(a1.changed, true);
|
||||
assert.deepEqual(base.dsh.profile.bundles, ['@deepseek-ai/dsh-base'], '纯函数不得改原对象');
|
||||
|
||||
// 幂等:再应用一次结果逐字节相同
|
||||
const a2 = applyProfileEdits(a1.next, BUNDLE_DIR);
|
||||
assert.equal(a2.changed, false);
|
||||
assert.deepEqual(a2.next, a1.next);
|
||||
|
||||
// 其它依赖与未知键保留
|
||||
assert.equal(a1.next.dependencies.other, '1.0.0');
|
||||
assert.equal(a1.next.name, 'p');
|
||||
|
||||
// 反向:删干净且幂等
|
||||
const r1 = removeProfileEdits(a1.next);
|
||||
assert.deepEqual(r1.next.dsh.profile.bundles, ['@deepseek-ai/dsh-base']);
|
||||
assert.equal(Object.hasOwn(r1.next.dependencies, BUNDLE_PACKAGE), false);
|
||||
assert.equal(r1.next.dependencies.other, '1.0.0');
|
||||
const r2 = removeProfileEdits(r1.next);
|
||||
assert.equal(r2.changed, false);
|
||||
assert.deepEqual(r2.next, r1.next);
|
||||
|
||||
// 空 profile 也能应用(缺 dsh / dependencies 时补齐)
|
||||
const fresh = applyProfileEdits({}, BUNDLE_DIR);
|
||||
assert.deepEqual(fresh.next.dsh.profile.bundles, [BUNDLE_PACKAGE]);
|
||||
assert.deepEqual(fresh.next.dependencies, { [BUNDLE_PACKAGE]: linkSpec(BUNDLE_DIR) });
|
||||
});
|
||||
|
||||
test('findPresetRow / dependencyMatchesBundle / resolveProfileDir', () => {
|
||||
const patch = '# comment\n- insert:\n - id: preset-rev-skills\n config:\n id: rev-skills\n';
|
||||
assert.deepEqual(findPresetRow(patch, PRESET_ID), { line: 3, id: 'preset-rev-skills', text: '- id: preset-rev-skills' });
|
||||
assert.equal(findPresetRow('- id: other\n', PRESET_ID), null);
|
||||
assert.equal(findPresetRow("- insert:\n - id: 'rev-skills'\n", PRESET_ID).line, 2);
|
||||
assert.equal(findPresetRow('- id: preset-rev-skills-extra\n', PRESET_ID), null);
|
||||
|
||||
const dir = BUNDLE_DIR;
|
||||
assert.equal(dependencyMatchesBundle(`link:${dir.split('\\').join('/')}`, dir), true);
|
||||
assert.equal(dependencyMatchesBundle(dir, dir), true);
|
||||
assert.equal(dependencyMatchesBundle(`file:${dir}`, dir), true);
|
||||
assert.equal(dependencyMatchesBundle('link:/somewhere/else', dir), false);
|
||||
assert.equal(dependencyMatchesBundle(undefined, dir), false);
|
||||
assert.equal(dependencyMatchesBundle('', dir), false);
|
||||
|
||||
assert.equal(resolveProfileDir('/tmp/x', {}), resolve('/tmp/x'));
|
||||
assert.equal(resolveProfileDir(null, { DSH_PROFILE_DIR: '/p1', DSH_HOME: '/h' }), '/p1');
|
||||
assert.equal(resolveProfileDir(null, { DSH_HOME: '/h' }), join('/h', 'profiles', 'desktop'));
|
||||
assert.match(resolveProfileDir(null, {}), /profiles[\\/]desktop$/);
|
||||
});
|
||||
|
||||
test('resolveLocalSkillRoot:REV_SKILLS_DIR 按补丁的优先级覆盖', () => {
|
||||
withTempDir('dsh-root-', (dir) => {
|
||||
const repoLike = join(dir, 'repo');
|
||||
mkdirSync(join(repoLike, '.claude', 'skills', 're-x'), { recursive: true });
|
||||
writeFileSync(join(repoLike, '.claude', 'skills', 're-x', 'SKILL.md'), skillFile('re-x'));
|
||||
|
||||
// 指向仓库根 → 展开成 <dir>/.claude/skills
|
||||
const viaRoot = resolveLocalSkillRoot({ REV_SKILLS_DIR: repoLike, DSH_HOME: join(dir, 'nope') }, repoLike);
|
||||
assert.equal(viaRoot.active.root, join(repoLike, '.claude', 'skills'));
|
||||
assert.equal(viaRoot.active.skills, 1);
|
||||
|
||||
// 直接指向技能根 → 原样使用
|
||||
const skillsDir = join(repoLike, '.claude', 'skills');
|
||||
const viaDir = resolveLocalSkillRoot({ REV_SKILLS_DIR: skillsDir, DSH_HOME: join(dir, 'nope') }, repoLike);
|
||||
assert.equal(viaDir.active.root, skillsDir);
|
||||
|
||||
// 无环境变量且 DSH 用户根为空 → 回落到仓库技能根
|
||||
const viaRepo = resolveLocalSkillRoot({ DSH_HOME: join(dir, 'nope') }, repoLike);
|
||||
assert.equal(viaRepo.active.root, skillsDir);
|
||||
|
||||
// DSH 用户根存在且含 re- 条目 → 优先于仓库根(与补丁候选顺序一致)
|
||||
const homeSkills = join(dir, 'home', 'skills');
|
||||
mkdirSync(join(homeSkills, 're-y'), { recursive: true });
|
||||
writeFileSync(join(homeSkills, 're-y', 'SKILL.md'), skillFile('re-y'));
|
||||
const viaHome = resolveLocalSkillRoot({ DSH_HOME: join(dir, 'home') }, repoLike);
|
||||
assert.equal(viaHome.active.root, homeSkills);
|
||||
|
||||
// 候选里带出「需要先装依赖才能判定」的包解析项
|
||||
assert.equal(viaHome.candidates.some((c) => c.resolvable === false), true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- CLI:preset status ----------
|
||||
|
||||
test('CLI preset status:一致时退出 0,未安装时退出 1,--json 可解析', () => {
|
||||
withTempDir('dsh-status-', (dir) => {
|
||||
const profileDir = join(dir, 'profile');
|
||||
mkdirSync(profileDir, { recursive: true });
|
||||
const profileFile = join(profileDir, 'package.json');
|
||||
|
||||
// 未安装
|
||||
writeJson(profileFile, { name: 'p', dependencies: {}, dsh: { profile: { bundles: ['@deepseek-ai/dsh-base'] } } });
|
||||
const off = runCli(['preset', 'status', '--profile-dir', profileDir, '--json']);
|
||||
assert.equal(off.status, 1);
|
||||
const offObj = JSON.parse(off.stdout);
|
||||
assert.equal(offObj.ok, false);
|
||||
assert.equal(offObj.listedInBundles, false);
|
||||
assert.equal(offObj.dependencyEntry, null);
|
||||
assert.equal(offObj.bundleExists, bundleExists);
|
||||
assert.equal(offObj.profileDir, resolve(profileDir));
|
||||
|
||||
// 已安装且一致(真的 bundle + 真的补丁行)
|
||||
const pkg = JSON.parse(readFileSync(profileFile, 'utf8'));
|
||||
const edited = applyProfileEdits(pkg, BUNDLE_DIR);
|
||||
writeFileSync(profileFile, `${JSON.stringify(edited.next, null, 2)}\n`);
|
||||
const on = runCli(['preset', 'status', '--profile-dir', profileDir, '--json']);
|
||||
assert.equal(on.status, 0, `stderr: ${on.stderr}`);
|
||||
const onObj = JSON.parse(on.stdout);
|
||||
assert.equal(onObj.ok, true);
|
||||
assert.equal(onObj.listedInBundles, true);
|
||||
assert.equal(onObj.dependencyMatchesBundle, true);
|
||||
assert.equal(onObj.presetRowFound, true, 'bundle 补丁里应有 config.id: rev-skills 的预设行');
|
||||
assert.ok(onObj.presetRow.line > 0);
|
||||
|
||||
// 文本形态同样给出判定行
|
||||
const text = runCli(['preset', 'status', '--profile-dir', profileDir]);
|
||||
assert.equal(text.status, 0);
|
||||
assert.match(text.stdout, /OK: 已安装且一致/);
|
||||
|
||||
// profile 不可读 → 退出 1 且带原因
|
||||
writeFileSync(profileFile, '{ not json');
|
||||
const broken = runCli(['preset', 'status', '--profile-dir', profileDir, '--json']);
|
||||
assert.equal(broken.status, 1);
|
||||
assert.match(JSON.parse(broken.stdout).profileError, /不是合法 JSON/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- CLI:preset install / uninstall ----------
|
||||
|
||||
test('CLI preset install:dry-run 不写文件,--apply 写前备份且幂等', () => {
|
||||
assert.equal(bundleExists, true, '根包即 bundle(dsh.patch.yml + dsh.bundle.patch),install 用例依赖它');
|
||||
withTempDir('dsh-install-', (dir) => {
|
||||
const profileFile = join(dir, 'package.json');
|
||||
const backupFile = join(dir, PROFILE_BACKUP);
|
||||
const original = `${JSON.stringify({ name: 'p', dependencies: {}, dsh: { profile: { bundles: ['@deepseek-ai/dsh-base'] } } }, null, 2)}\n`;
|
||||
writeFileSync(profileFile, original);
|
||||
|
||||
// dry-run:内容逐字节不变,且打印 before/after
|
||||
const dry = runCli(['preset', 'install', '--profile-dir', dir]);
|
||||
assert.equal(dry.status, 0, `stderr: ${dry.stderr}`);
|
||||
assert.equal(readFileSync(profileFile, 'utf8'), original);
|
||||
assert.equal(existsSync(backupFile), false);
|
||||
assert.match(dry.stdout, /before: /);
|
||||
assert.match(dry.stdout, /after: /);
|
||||
assert.ok(dry.stdout.includes(BUNDLE_PACKAGE));
|
||||
assert.ok(dry.stdout.includes(`link:${resolve(BUNDLE_DIR).split('\\').join('/')}`));
|
||||
|
||||
// --apply:写入 + 备份 + 打出剩余手动步骤(pnpm install + 重启)
|
||||
const app = runCli(['preset', 'install', '--profile-dir', dir, '--apply']);
|
||||
assert.equal(app.status, 0, `stderr: ${app.stderr}`);
|
||||
assert.equal(readFileSync(backupFile, 'utf8'), original, '备份必须是写入前的原文');
|
||||
const written = readFileSync(profileFile, 'utf8');
|
||||
const edited = JSON.parse(written);
|
||||
assert.deepEqual(edited.dsh.profile.bundles, ['@deepseek-ai/dsh-base', BUNDLE_PACKAGE]);
|
||||
assert.equal(edited.dependencies[BUNDLE_PACKAGE], `link:${resolve(BUNDLE_DIR).split('\\').join('/')}`);
|
||||
assert.match(app.stdout, /pnpm install/);
|
||||
assert.match(app.stdout, /重启 DSH/);
|
||||
|
||||
// 备份已存在 → 拒绝覆盖且不改文件
|
||||
const again = runCli(['preset', 'install', '--profile-dir', dir, '--apply']);
|
||||
assert.equal(again.status, 1);
|
||||
assert.match(again.stderr, /拒绝覆盖/);
|
||||
assert.equal(readFileSync(profileFile, 'utf8'), written);
|
||||
|
||||
// --force:允许覆盖备份,且结果幂等(已装好就不再变)
|
||||
const forced = runCli(['preset', 'install', '--profile-dir', dir, '--apply', '--force']);
|
||||
assert.equal(forced.status, 0, `stderr: ${forced.stderr}`);
|
||||
assert.equal(readFileSync(profileFile, 'utf8'), written);
|
||||
assert.equal(readFileSync(backupFile, 'utf8'), original, '--force 只覆盖备份,不应污染备份内容');
|
||||
|
||||
// 应用后再 dry-run:after 与现状一致
|
||||
const dry2 = runCli(['preset', 'install', '--profile-dir', dir]);
|
||||
assert.equal(dry2.status, 0);
|
||||
assert.equal(readFileSync(profileFile, 'utf8'), written);
|
||||
});
|
||||
});
|
||||
|
||||
test('CLI preset uninstall:dry-run 不写,--apply 从备份还原并保留备份', () => {
|
||||
withTempDir('dsh-uninstall-', (dir) => {
|
||||
const profileFile = join(dir, 'package.json');
|
||||
const backupFile = join(dir, PROFILE_BACKUP);
|
||||
const original = `${JSON.stringify({ name: 'p', dependencies: { other: '1.0.0' }, dsh: { profile: { bundles: ['@deepseek-ai/dsh-base'] } } }, null, 2)}\n`;
|
||||
const installed = { name: 'p', dependencies: { other: '1.0.0', [BUNDLE_PACKAGE]: linkSpec(BUNDLE_DIR) }, dsh: { profile: { bundles: ['@deepseek-ai/dsh-base', BUNDLE_PACKAGE] } } };
|
||||
|
||||
// 已安装 + 有备份:dry-run 不写
|
||||
writeJson(profileFile, installed);
|
||||
writeFileSync(backupFile, original);
|
||||
const dry = runCli(['preset', 'uninstall', '--profile-dir', dir]);
|
||||
assert.equal(dry.status, 0, `stderr: ${dry.stderr}`);
|
||||
assert.equal(readFileSync(profileFile, 'utf8'), `${JSON.stringify(installed, null, 2)}\n`);
|
||||
assert.match(dry.stdout, /备份保留/);
|
||||
assert.match(dry.stdout, /before: /);
|
||||
assert.match(dry.stdout, /after: /);
|
||||
|
||||
// --apply:从备份还原原文,且备份保留
|
||||
const un = runCli(['preset', 'uninstall', '--profile-dir', dir, '--apply']);
|
||||
assert.equal(un.status, 0, `stderr: ${un.stderr}`);
|
||||
assert.equal(readFileSync(profileFile, 'utf8'), original, '有备份时应从备份还原原文');
|
||||
assert.equal(existsSync(backupFile), true, '还原后备份要保留');
|
||||
|
||||
// 幂等:再卸一次不改文件
|
||||
writeJson(profileFile, installed);
|
||||
const un2 = runCli(['preset', 'uninstall', '--profile-dir', dir, '--apply', '--json']);
|
||||
assert.equal(un2.status, 0, `stderr: ${un2.stderr}`);
|
||||
assert.equal(readFileSync(profileFile, 'utf8'), original);
|
||||
assert.equal(JSON.parse(un2.stdout).restoreFromBackup, true);
|
||||
|
||||
// 无备份时按编辑结果写回:只去掉 bundle 行与依赖键,其它键原样保留
|
||||
rmSync(backupFile, { force: true });
|
||||
writeJson(profileFile, installed);
|
||||
const noBak = runCli(['preset', 'uninstall', '--profile-dir', dir, '--apply']);
|
||||
assert.equal(noBak.status, 0, `stderr: ${noBak.stderr}`);
|
||||
const cleaned = JSON.parse(readFileSync(profileFile, 'utf8'));
|
||||
assert.deepEqual(cleaned.dsh.profile.bundles, ['@deepseek-ai/dsh-base']);
|
||||
assert.equal(Object.hasOwn(cleaned.dependencies, BUNDLE_PACKAGE), false);
|
||||
assert.equal(cleaned.dependencies.other, '1.0.0');
|
||||
assert.equal(existsSync(backupFile), false);
|
||||
|
||||
// 备份内容本身不可解析时:拒绝并保持文件不变(无部分写)
|
||||
const brokenBak = join(dir, PROFILE_BACKUP);
|
||||
writeFileSync(brokenBak, '{ not json');
|
||||
const beforeBroken = readFileSync(profileFile, 'utf8');
|
||||
const refused = runCli(['preset', 'uninstall', '--profile-dir', dir, '--apply']);
|
||||
assert.equal(refused.status, 1);
|
||||
assert.match(refused.stderr, /备份不是合法 JSON/);
|
||||
assert.equal(readFileSync(profileFile, 'utf8'), beforeBroken);
|
||||
});
|
||||
});
|
||||
|
||||
test('CLI preset install/uninstall:profile 不可读时退出 1 且不产生任何写入', () => {
|
||||
withTempDir('dsh-partial-', (dir) => {
|
||||
const profileFile = join(dir, 'package.json');
|
||||
const broken = '{ "name": "p", ';
|
||||
writeFileSync(profileFile, broken);
|
||||
for (const sub of ['install', 'uninstall']) {
|
||||
const r = runCli(['preset', sub, '--profile-dir', dir, '--apply']);
|
||||
assert.equal(r.status, 1, `${sub} 应退出 1`);
|
||||
assert.match(r.stderr, /不是合法 JSON|不存在/);
|
||||
assert.equal(readFileSync(profileFile, 'utf8'), broken, `${sub} 不得改动文件`);
|
||||
assert.equal(existsSync(join(dir, PROFILE_BACKUP)), false, `${sub} 不得留下备份`);
|
||||
assert.equal(existsSync(`${profileFile}.rev-skills.tmp`), false, `${sub} 不得留下临时文件`);
|
||||
}
|
||||
// profile 根本不存在:同样退出 1 且什么都不建
|
||||
const empty = join(dir, 'nope');
|
||||
mkdirSync(empty);
|
||||
const r = runCli(['preset', 'install', '--profile-dir', empty, '--apply']);
|
||||
assert.equal(r.status, 1);
|
||||
assert.match(r.stderr, /不存在/);
|
||||
assert.deepEqual(readdirSync(empty), []);
|
||||
});
|
||||
});
|
||||
90
vendor-new/rev-skills/tests/examples.test.mjs
Normal file
90
vendor-new/rev-skills/tests/examples.test.mjs
Normal file
@@ -0,0 +1,90 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { dedent, splitHeredocs, normalizeShell, checkExamples, available, collectExamples } from '../lib/skill-examples.mjs';
|
||||
|
||||
test('dedent 去掉列表项缩进', () => {
|
||||
assert.equal(dedent(' a\n b\n'), 'a\n b\n');
|
||||
});
|
||||
|
||||
test('splitHeredocs:python heredoc 体被拆出,shell 部分保留', () => {
|
||||
const { shell, heredocs } = splitHeredocs([
|
||||
'python3 - <<PY',
|
||||
'import onnx',
|
||||
'print(1)',
|
||||
'PY',
|
||||
'echo done',
|
||||
].join('\n'));
|
||||
assert.equal(heredocs.length, 1);
|
||||
assert.equal(heredocs[0].lang, 'python');
|
||||
assert.match(heredocs[0].body, /import onnx/);
|
||||
assert.match(shell, /echo done/);
|
||||
assert.doesNotMatch(shell, /import onnx/);
|
||||
});
|
||||
|
||||
test('normalizeShell:占位符中和(含带空格的形式)', () => {
|
||||
assert.match(normalizeShell('gcore -o out <pid>\n'), /PLACEHOLDER/);
|
||||
assert.match(normalizeShell('python3 dump.py <BundleID 或 App 名>\n'), /PLACEHOLDER/);
|
||||
});
|
||||
|
||||
test('normalizeShell:会话记录块丢弃提示符行', () => {
|
||||
const transcript = ['jdb -classpath app.jar com.example.Main', '> stop in Foo.bar', '> print key'].join('\n');
|
||||
const out = normalizeShell(transcript);
|
||||
assert.match(out, /^jdb /);
|
||||
assert.doesNotMatch(out, /stop in/);
|
||||
});
|
||||
|
||||
test('normalizeShell:匿名调试器提示符(无启动行)也不进 shell 检查', () => {
|
||||
const out = normalizeShell('(gdb) set disable-randomization on\n(gdb) b main\n');
|
||||
assert.doesNotMatch(out, /disable-randomization/);
|
||||
});
|
||||
|
||||
const SHA = available();
|
||||
function fakeSkill(body) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'ex-'));
|
||||
const dir = join(root, 're-x');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, 'SKILL.md'), body);
|
||||
return root;
|
||||
}
|
||||
|
||||
test('checkExamples 报出 python 语法错', { skip: !SHA.python }, () => {
|
||||
const root = fakeSkill('```python\nsimgr.explore(find=0x4011xx, avoid=[])\n```\n');
|
||||
const { errors } = checkExamples(root);
|
||||
assert.equal(errors.length, 1);
|
||||
assert.match(errors[0].msg, /hexadecimal|invalid/i);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('checkExamples 不误报带占位符的 shell 块', { skip: !SHA.shell }, () => {
|
||||
const root = fakeSkill('```sh\nvol -f dump.raw windows.dlllist -p <pid>\n```\n');
|
||||
const { errors } = checkExamples(root);
|
||||
assert.deepEqual(errors, []);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('checkExamples 检查 sh 块里嵌的 python heredoc', { skip: !SHA.python }, () => {
|
||||
const root = fakeSkill('```sh\npython3 - <<PY\nx = 0x1234xx\nPY\n```\n');
|
||||
const { errors } = checkExamples(root);
|
||||
assert.equal(errors.length, 1);
|
||||
assert.match(errors[0].msg, /^heredoc:/);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('collectExamples 收集技能下的全部 md(含 references)', () => {
|
||||
const root = fakeSkill('# x\n');
|
||||
mkdirSync(join(root, 're-x', 'references'), { recursive: true });
|
||||
writeFileSync(join(root, 're-x', 'references', 'a.md'), '```sh\necho hi\n```\n');
|
||||
const blocks = collectExamples(root);
|
||||
assert.equal(blocks.length, 1);
|
||||
assert.equal(blocks[0].skill, 're-x');
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('仓库自身示例语法通过(有解释器时才跑)', { skip: !(SHA.python && SHA.shell) }, () => {
|
||||
const { errors, checked } = checkExamples('.claude/skills');
|
||||
assert.ok(checked > 100, `检查块数偏少:${checked}`);
|
||||
assert.deepEqual(errors, [], `示例语法错误:\n${errors.map((e) => `${e.file}:${e.line} ${e.msg}`).join('\n')}`);
|
||||
});
|
||||
106
vendor-new/rev-skills/tests/fingerprints.test.mjs
Normal file
106
vendor-new/rev-skills/tests/fingerprints.test.mjs
Normal file
@@ -0,0 +1,106 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
|
||||
// system-fingerprints.md 的维护约定是「新增系统分支须同步加行」——本测试把它变成检查:
|
||||
// 任何一个分支文件必须出现在 MAPPED(映射到指纹表里的某个名字)或 EXEMPT(非系统文件,须写明理由)。
|
||||
// 新增分支却两处都没登记 → 红线;这样"约定"就不会随时间失效。
|
||||
const FP = '.claude/skills/re-analyze/references/system-fingerprints.md';
|
||||
const ROOT = '.claude/skills/';
|
||||
const DIRS = ['re-kernel', 're-rtos', 're-hypervisor', 're-automotive', 're-uefi'];
|
||||
|
||||
// 分支文件 → 指纹表里应出现的关键字(可用多个,任选其一命中即可)
|
||||
const MAPPED = {
|
||||
're-kernel/windows-kernel': ['Windows'],
|
||||
're-kernel/linux-kernel': ['Linux'],
|
||||
're-kernel/macos-kernel': ['macOS'],
|
||||
're-kernel/android-kernel': ['Android'],
|
||||
're-kernel/sel4-kernel': ['seL4'],
|
||||
're-kernel/zircon-kernel': ['Fuchsia'],
|
||||
're-kernel/freebsd-kernel': ['FreeBSD'],
|
||||
're-kernel/netbsd-kernel': ['NetBSD'],
|
||||
're-kernel/openbsd-kernel': ['OpenBSD'],
|
||||
're-kernel/illumos-kernel': ['illumos'],
|
||||
're-kernel/genode': ['Genode'],
|
||||
're-kernel/minix3': ['MINIX 3'],
|
||||
're-kernel/plan9': ['Plan 9'],
|
||||
're-kernel/helenos': ['HelenOS'],
|
||||
're-kernel/redox': ['Redox'],
|
||||
're-kernel/haiku': ['Haiku'],
|
||||
're-kernel/hic': ['HIC'],
|
||||
're-kernel/zos': ['z/OS'],
|
||||
're-kernel/ibmi': ['IBM i'],
|
||||
're-kernel/openvms': ['OpenVMS'],
|
||||
're-kernel/unikraft-mirageos': ['Unikraft', 'MirageOS'],
|
||||
're-rtos/qnx': ['QNX Neutrino'],
|
||||
're-rtos/vxworks': ['VxWorks'],
|
||||
're-rtos/zephyr': ['Zephyr'],
|
||||
're-rtos/rtems': ['RTEMS'],
|
||||
're-rtos/nuttx': ['NuttX'],
|
||||
're-rtos/ecos': ['eCos'],
|
||||
're-rtos/freertos-context': ['FreeRTOS'],
|
||||
're-rtos/threadx-modules': ['ThreadX Modules', 'ThreadX'],
|
||||
're-rtos/safertos-rtx5': ['SAFERTOS'],
|
||||
're-rtos/tkernel-toppers': ['T-Kernel', 'TOPPERS'],
|
||||
're-rtos/ose-oseck': ['OSE / OSEck', 'OSE'],
|
||||
're-rtos/nucleus': ['Nucleus'],
|
||||
're-rtos/ucos-sysbios': ['µC/OS', 'SYS/BIOS'],
|
||||
're-rtos/partitioned-rtos': ['INTEGRITY', 'PikeOS', 'ARINC 653', 'Deos'],
|
||||
're-hypervisor/xen': ['Xen'],
|
||||
're-hypervisor/qnx-hypervisor': ['QNX Hypervisor'],
|
||||
're-hypervisor/jailhouse': ['Jailhouse'],
|
||||
're-hypervisor/acrn': ['ACRN'],
|
||||
're-hypervisor/bao': ['Bao'],
|
||||
're-hypervisor/hyperv-vmbus': ['Hyper-V'],
|
||||
're-hypervisor/xtratum': ['XtratuM'],
|
||||
're-hypervisor/lynxsecure': ['LynxSecure'],
|
||||
're-hypervisor/questv': ['Quest-V'],
|
||||
're-automotive/autosar-classic': ['AUTOSAR Classic'],
|
||||
're-automotive/autosar-adaptive': ['AUTOSAR Adaptive'],
|
||||
're-uefi/pi-stages': ['UEFI'],
|
||||
};
|
||||
|
||||
// 非系统分支(平台专属补充资料,不属于"某个系统")
|
||||
const EXEMPT = {
|
||||
're-kernel/windows-gotchas': 'Windows 版本差异组,随 windows-kernel 分支',
|
||||
're-kernel/windows-decision-tree': 'Windows 场景决策树,随 windows-kernel 分支',
|
||||
};
|
||||
|
||||
function branchFiles() {
|
||||
const out = [];
|
||||
for (const d of DIRS) {
|
||||
let names;
|
||||
try { names = readdirSync(`${ROOT}${d}/references`); } catch { continue; }
|
||||
for (const n of names) {
|
||||
if (n.endsWith('.md')) out.push(`${d}/${n.replace(/\.md$/, '')}`);
|
||||
}
|
||||
}
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
test('每个系统分支都在指纹表里有对应条目', () => {
|
||||
const fp = readFileSync(FP, 'utf8');
|
||||
const bad = [];
|
||||
for (const [file, keys] of Object.entries(MAPPED)) {
|
||||
if (!keys.some((k) => fp.includes(k))) bad.push(`${file}:指纹表里找不到 ${keys.join(' / ')}`);
|
||||
}
|
||||
assert.deepEqual(bad, [], `指纹表缺条目:\n${bad.join('\n')}`);
|
||||
});
|
||||
|
||||
test('新增分支必须登记到映射或豁免表(防约定失效)', () => {
|
||||
const known = new Set([...Object.keys(MAPPED), ...Object.keys(EXEMPT)]);
|
||||
const unregistered = branchFiles().filter((f) => !known.has(f));
|
||||
assert.deepEqual(unregistered, [],
|
||||
`以下分支既未映射到指纹表、也未列入豁免——请补 MAPPED(并在指纹表加行)或写明豁免理由:\n${unregistered.join('\n')}`);
|
||||
});
|
||||
|
||||
test('映射表引用的分支文件都还存在(防改名后残留)', () => {
|
||||
const files = new Set(branchFiles());
|
||||
const stale = [...Object.keys(MAPPED), ...Object.keys(EXEMPT)].filter((f) => !files.has(f));
|
||||
assert.deepEqual(stale, [], `映射表里有已不存在的分支:${stale.join(' ')}`);
|
||||
});
|
||||
|
||||
test('豁免项必须写明理由', () => {
|
||||
const empty = Object.entries(EXEMPT).filter(([, why]) => !why || why.length < 4).map(([f]) => f);
|
||||
assert.deepEqual(empty, [], `豁免项缺理由:${empty.join(' ')}`);
|
||||
});
|
||||
14
vendor-new/rev-skills/tests/fixtures-dsh/README.md
Normal file
14
vendor-new/rev-skills/tests/fixtures-dsh/README.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# DSH 检查夹具
|
||||
|
||||
这些目录是 `bin/dsh.mjs check` 的输入,不属于 `.claude/skills`(`validate.mjs` 不扫这里)。
|
||||
|
||||
- `check-errors/` 每个子目录只违反一条规则,用来确认每个错误类都能被点名;`re-ok/` 是合法对照。
|
||||
- `flat-root/` 覆盖平铺形态(`<root>/<name>.md`)与平铺名文法检查。
|
||||
- `long/` 是一个超过工具结果裁剪阈值的合法技能,用来确认「超阈值只告警不报错」。
|
||||
|
||||
放在 `tests/fixtures-dsh/` 而不是 `tests/fixtures/dsh-*/`:`tests/validate.test.mjs` 里
|
||||
`collectSkills` 断言了 `tests/fixtures/` 一级目录的完整名单,往里加任何目录都会让那条断言变红,
|
||||
而那个文件不在本任务的改动范围内。
|
||||
|
||||
夹具里的 `re-tabs/SKILL.md` 含一个真实的 TAB 字符(YAML 不允许用 TAB 缩进),
|
||||
测试会先断言该文件确实含 TAB,再断言检查器报 `tab-in-frontmatter`。
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
name: re-hidden
|
||||
description: 夹具:位于隐藏目录下,发现阶段就应跳过。Hidden entry, skipped by discovery.
|
||||
type: atomic
|
||||
---
|
||||
|
||||
## 什么时候用
|
||||
|
||||
夹具:DSH 只扫技能根的一级目录,而本目录以点开头,属于应跳过的项。
|
||||
@@ -0,0 +1 @@
|
||||
夹具:技能根一级的非 .md 文件。DSH 不认,检查器应把它计入 skipped 而不是技能。
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
name: re-Bad_Name
|
||||
description: 夹具:名字不符合 kebab-case 文法。Invalid kebab-case name.
|
||||
type: atomic
|
||||
---
|
||||
|
||||
## 什么时候用
|
||||
|
||||
下划线与大写都不在 DSH 的 `^[a-z0-9]+(?:-[a-z0-9]+)*$` 之内。
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
name: re-bad-bool
|
||||
description: 夹具:布尔字段写了非法值。Invalid boolean spelling.
|
||||
type: atomic
|
||||
user-invocable: maybe
|
||||
---
|
||||
|
||||
## 什么时候用
|
||||
|
||||
DSH 的布尔文法只接受 true/false、yes/no、on/off、1/0,`maybe` 会让该技能被丢弃。
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
name: re-empty-body
|
||||
description: 夹具:闭合分界行之后没有正文。Empty body after the closing delimiter.
|
||||
type: atomic
|
||||
---
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
---
|
||||
|
||||
## 什么时候用
|
||||
|
||||
夹具:两条分界行之间没有任何内容。此时 YAML 解析结果是 null 而不是映射,DSH 不认。
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: re-hyphen-keys
|
||||
description: 夹具:带连字符的可选键。Hyphenated optional keys, valid for DSH.
|
||||
type: atomic
|
||||
disable-model-invocation: false
|
||||
user-invocable: true
|
||||
---
|
||||
|
||||
## 什么时候用
|
||||
|
||||
夹具:`disable-model-invocation` 与 `user-invocable` 是 DSH 的合法可选键(都是合法 YAML)。
|
||||
仓库的 `lib/frontmatter.mjs` 键文法只认 `\w+`,会把它们判成游离行——本夹具就是那条误报的回归。
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
name: re-legacy-key
|
||||
description: 夹具:用了驼峰 legacy 键。Legacy camelCase invocation key.
|
||||
type: atomic
|
||||
disableModelInvocation: true
|
||||
---
|
||||
|
||||
## 什么时候用
|
||||
|
||||
DSH 只认 `disable-model-invocation`,驼峰写法会让整个技能被丢弃。
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
name: re-other
|
||||
description: 夹具:name 与目录名不一致。Name does not match the directory.
|
||||
type: atomic
|
||||
---
|
||||
|
||||
## 什么时候用
|
||||
|
||||
目录是 `re-name-mismatch`,frontmatter 里写的是 `re-other`。
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
name: re-no-close
|
||||
description: 夹具:缺少闭合的分界行。Missing closing delimiter.
|
||||
|
||||
## 什么时候用
|
||||
|
||||
下面这段本该是正文,但因为没有闭合的 `---`,整个文件都不是合法技能。
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
name: re-no-desc
|
||||
---
|
||||
|
||||
## 什么时候用
|
||||
|
||||
夹具:只有 name,没有 description(DSH 要求 description 非空)。
|
||||
@@ -0,0 +1,3 @@
|
||||
# 没有 frontmatter 的技能
|
||||
|
||||
夹具:首行不是裸 `---`,DSH 直接忽略该文件(记 warning,不报错)。
|
||||
@@ -0,0 +1,5 @@
|
||||
# re-no-skill-md
|
||||
|
||||
夹具:目录存在但没有 SKILL.md(DSH 只认 `<name>/SKILL.md`,这个目录会被忽略)。
|
||||
|
||||
用 git 无法提交空目录,所以放这个说明文件占位。
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
- 顶层是序列,不是映射
|
||||
- top level is a sequence, not a mapping
|
||||
---
|
||||
|
||||
## 什么时候用
|
||||
|
||||
夹具:frontmatter 顶层是列表。DSH 要求解析结果是映射,列表会被忽略。
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
name: re-ok
|
||||
description: 夹具:一切合法。Valid skill fixture, no rule may fire.
|
||||
type: atomic
|
||||
---
|
||||
|
||||
## 什么时候用
|
||||
|
||||
夹具:合法技能。检查器对它不应报任何错误或告警。
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
name: re-tabs
|
||||
description: 夹具:frontmatter 里出现 TAB 字符。TAB inside frontmatter.
|
||||
type: atomic
|
||||
这一行的缩进是 TAB,YAML 不允许用 TAB 做缩进
|
||||
---
|
||||
|
||||
## 什么时候用
|
||||
|
||||
夹具:frontmatter 块内出现 TAB 即报 `tab-in-frontmatter`(DSH 的 YAML 解析器会拒绝这种缩进)。
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
name: Not-Kebab
|
||||
description: 夹具:平铺文件名不符合 kebab-case。Invalid flat-file name.
|
||||
type: atomic
|
||||
---
|
||||
|
||||
## 什么时候用
|
||||
|
||||
平铺技能的名字取自文件名,大写与下划线都不在 DSH 的文法内。
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
name: re-flat
|
||||
description: 夹具:平铺形态的合法技能。Valid flat-file skill.
|
||||
type: atomic
|
||||
---
|
||||
|
||||
## 什么时候用
|
||||
|
||||
DSH 认两种形态:`<root>/<name>/SKILL.md` 与 `<root>/<name>.md`,本夹具是后者。
|
||||
130
vendor-new/rev-skills/tests/fixtures-dsh/long/re-long/SKILL.md
Normal file
130
vendor-new/rev-skills/tests/fixtures-dsh/long/re-long/SKILL.md
Normal file
@@ -0,0 +1,130 @@
|
||||
---
|
||||
name: re-long
|
||||
description: Fixture: a valid skill whose file exceeds the 8192-character tool-result prune threshold.
|
||||
type: atomic
|
||||
---
|
||||
|
||||
## Body
|
||||
|
||||
Fixture: valid frontmatter and a body long enough to trigger the prune-threshold warning (warning only, never an error).
|
||||
|
||||
- filler 0001: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0002: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0003: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0004: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0005: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0006: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0007: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0008: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0009: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0010: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0011: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0012: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0013: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0014: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0015: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0016: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0017: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0018: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0019: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0020: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0021: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0022: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0023: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0024: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0025: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0026: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0027: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0028: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0029: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0030: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0031: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0032: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0033: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0034: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0035: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0036: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0037: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0038: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0039: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0040: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0041: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0042: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0043: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0044: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0045: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0046: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0047: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0048: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0049: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0050: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0051: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0052: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0053: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0054: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0055: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0056: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0057: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0058: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0059: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0060: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0061: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0062: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0063: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0064: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0065: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0066: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0067: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0068: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0069: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0070: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0071: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0072: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0073: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0074: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0075: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0076: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0077: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0078: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0079: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0080: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0081: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0082: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0083: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0084: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0085: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0086: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0087: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0088: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0089: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0090: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0091: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0092: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0093: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0094: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0095: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0096: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0097: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0098: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0099: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0100: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0101: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0102: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0103: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0104: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0105: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0106: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0107: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0108: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0109: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0110: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0111: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0112: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0113: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0114: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0115: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0116: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0117: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0118: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0119: this line only pushes the file past the 8192-character threshold.
|
||||
- filler 0120: this line only pushes the file past the 8192-character threshold.
|
||||
7
vendor-new/rev-skills/tests/fixtures/bad-frontmatter/SKILL.md
vendored
Normal file
7
vendor-new/rev-skills/tests/fixtures/bad-frontmatter/SKILL.md
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
name: bad-frontmatter
|
||||
description: >
|
||||
块标量正文里误写顶层列表项,整行会被解析器丢弃。
|
||||
- 这一行顶格且不是 key:,属于非法写法
|
||||
触发词:frontmatter、游离行、bad-frontmatter
|
||||
---
|
||||
10
vendor-new/rev-skills/tests/fixtures/bad-name/SKILL.md
vendored
Normal file
10
vendor-new/rev-skills/tests/fixtures/bad-name/SKILL.md
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
name: wrong-name
|
||||
description: 名字与目录不符。name mismatch.
|
||||
---
|
||||
|
||||
# Bad Name
|
||||
|
||||
## 工具准备
|
||||
|
||||
tool
|
||||
14
vendor-new/rev-skills/tests/fixtures/broken-link/SKILL.md
vendored
Normal file
14
vendor-new/rev-skills/tests/fixtures/broken-link/SKILL.md
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
---
|
||||
name: broken-link
|
||||
description: 含死链接与合法 references 链接。broken link.
|
||||
---
|
||||
|
||||
# Broken Link
|
||||
|
||||
## 工具准备
|
||||
|
||||
tool
|
||||
|
||||
## 跨域联合
|
||||
|
||||
引用 [[re-does-not-exist]]、跨技能裸引用 [[platform-tips]]、本技能内引用 [[local-note]]
|
||||
3
vendor-new/rev-skills/tests/fixtures/broken-link/references/local-note.md
vendored
Normal file
3
vendor-new/rev-skills/tests/fixtures/broken-link/references/local-note.md
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# 本技能内的引用目标
|
||||
|
||||
测试用。
|
||||
11
vendor-new/rev-skills/tests/fixtures/gateway-skill/SKILL.md
vendored
Normal file
11
vendor-new/rev-skills/tests/fixtures/gateway-skill/SKILL.md
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
---
|
||||
name: gateway-skill
|
||||
description: 网关型技能,无工具准备章节。gateway skill.
|
||||
type: gateway
|
||||
---
|
||||
|
||||
# Gateway Skill
|
||||
|
||||
## 完整工作流
|
||||
|
||||
workflow
|
||||
24
vendor-new/rev-skills/tests/fixtures/good-skill/SKILL.md
vendored
Normal file
24
vendor-new/rev-skills/tests/fixtures/good-skill/SKILL.md
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
name: good-skill
|
||||
description: 测试用合法技能。Trigger: test。
|
||||
capabilities: [triage]
|
||||
---
|
||||
|
||||
# Good Skill
|
||||
|
||||
## 何时使用 / 何时不用
|
||||
|
||||
测试用。
|
||||
|
||||
## 工具准备
|
||||
|
||||
```sh
|
||||
# Linux
|
||||
apt install example-tool
|
||||
```
|
||||
|
||||
## 操作步骤
|
||||
|
||||
## 常见坑与陷阱
|
||||
|
||||
## 跨域联合
|
||||
10
vendor-new/rev-skills/tests/fixtures/no-tools/SKILL.md
vendored
Normal file
10
vendor-new/rev-skills/tests/fixtures/no-tools/SKILL.md
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
name: no-tools
|
||||
description: 缺工具准备章节。missing tools section.
|
||||
---
|
||||
|
||||
# No Tools
|
||||
|
||||
## 操作步骤
|
||||
|
||||
nothing
|
||||
204
vendor-new/rev-skills/tests/format-fixtures.test.mjs
Normal file
204
vendor-new/rev-skills/tests/format-fixtures.test.mjs
Normal file
@@ -0,0 +1,204 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// 格式断言 fixture:技能里对**字节布局**的断言,可以用纯代码构造的最小样本验证,
|
||||
// 不依赖网络与外部样本。这类断言最贵——写错一个字整条解析链都错。
|
||||
// 首个 fixture 对应 re-format-elf/references/layout.md 的「扩展编号三套规则」
|
||||
// (该处原文即注明"建议配 parser fixture 验证这三条路径")。
|
||||
|
||||
const U16 = (b, o) => b.readUInt16LE(o);
|
||||
const U32 = (b, o) => b.readUInt32LE(o);
|
||||
const U64 = (b, o) => Number(b.readBigUInt64LE(o));
|
||||
|
||||
/** 构造一个带扩展编号的 ELF64:三个哨兵值全用上,真值放在 section 0 */
|
||||
function buildElfWithExtendedNumbering({ phnum = 2, shnum = 3, shstrndx = 2 } = {}) {
|
||||
const EHSIZE = 64, PHENTSIZE = 56, SHENTSIZE = 64;
|
||||
const e_phoff = EHSIZE;
|
||||
const e_shoff = EHSIZE + phnum * PHENTSIZE;
|
||||
const buf = Buffer.alloc(e_shoff + shnum * SHENTSIZE);
|
||||
|
||||
// e_ident
|
||||
buf.write('\x7fELF', 0, 'latin1');
|
||||
buf[4] = 2; // EI_CLASS = ELFCLASS64
|
||||
buf[5] = 1; // EI_DATA = ELFDATA2LSB
|
||||
buf[6] = 1; // EI_VERSION
|
||||
buf[7] = 0; // EI_OSABI = NONE
|
||||
|
||||
buf.writeUInt16LE(2, 16); // e_type = ET_EXEC
|
||||
buf.writeUInt16LE(0x3e, 18); // e_machine = x86-64
|
||||
buf.writeUInt32LE(1, 20); // e_version
|
||||
buf.writeBigUInt64LE(0n, 24); // e_entry
|
||||
buf.writeBigUInt64LE(BigInt(e_phoff), 32);
|
||||
buf.writeBigUInt64LE(BigInt(e_shoff), 40);
|
||||
buf.writeUInt32LE(0, 48); // e_flags
|
||||
buf.writeUInt16LE(EHSIZE, 52); // e_ehsize
|
||||
buf.writeUInt16LE(PHENTSIZE, 54); // e_phentsize
|
||||
buf.writeUInt16LE(0xffff, 56); // e_phnum = PN_XNUM ← 哨兵 1
|
||||
buf.writeUInt16LE(SHENTSIZE, 58); // e_shentsize
|
||||
buf.writeUInt16LE(0, 60); // e_shnum = 0 ← 哨兵 2
|
||||
buf.writeUInt16LE(0xffff, 62); // e_shstrndx = SHN_XINDEX ← 哨兵 3
|
||||
|
||||
// section 0:SHT_NULL,承载三个真值
|
||||
const s0 = e_shoff;
|
||||
buf.writeUInt32LE(0, s0 + 0); // sh_name
|
||||
buf.writeUInt32LE(0, s0 + 4); // sh_type = SHT_NULL
|
||||
buf.writeBigUInt64LE(BigInt(shnum), s0 + 32); // sh_size = 真实节数(offset 是 Number,值是 BigInt)
|
||||
buf.writeUInt32LE(shstrndx, s0 + 40); // sh_link = 真实 shstrndx
|
||||
buf.writeUInt32LE(phnum, s0 + 44); // sh_info = 真实程序头数
|
||||
return buf;
|
||||
}
|
||||
|
||||
/** 按 layout.md 的三套规则解析(与被测断言一一对应) */
|
||||
function parsePerExtendedNumberingRules(buf) {
|
||||
const e_phnum = U16(buf, 56), e_shnum = U16(buf, 60), e_shstrndx = U16(buf, 62);
|
||||
const e_shoff = U64(buf, 40);
|
||||
const sh = (i, field) => { // 节头表里第 i 项的某字段
|
||||
const base = e_shoff + i * U16(buf, 58);
|
||||
return { name: U32(buf, base), type: U32(buf, base + 4), size: U64(buf, base + 32), link: U32(buf, base + 40), info: U32(buf, base + 44), }[field];
|
||||
};
|
||||
return {
|
||||
phnum: e_phnum === 0xffff ? sh(0, 'info') : e_phnum, // PN_XNUM
|
||||
shnum: e_shnum === 0 ? sh(0, 'size') : e_shnum, // e_shnum == 0
|
||||
shstrndx: e_shstrndx === 0xffff ? sh(0, 'link') : e_shstrndx, // SHN_XINDEX
|
||||
};
|
||||
}
|
||||
|
||||
test('ELF 扩展编号三套规则:三个哨兵值分别落在 shdr[0] 的 sh_info / sh_size / sh_link', () => {
|
||||
const buf = buildElfWithExtendedNumbering({ phnum: 7, shnum: 11, shstrndx: 5 });
|
||||
// 哨兵确实写在头部(否则测的不是扩展编号这条路径)
|
||||
assert.equal(U16(buf, 56), 0xffff, 'e_phnum 应为 PN_XNUM');
|
||||
assert.equal(U16(buf, 60), 0, 'e_shnum 应为 0');
|
||||
assert.equal(U16(buf, 62), 0xffff, 'e_shstrndx 应为 SHN_XINDEX');
|
||||
// 按规则解析出的真值
|
||||
const r = parsePerExtendedNumberingRules(buf);
|
||||
assert.deepEqual(r, { phnum: 7, shnum: 11, shstrndx: 5 });
|
||||
// 三条规则落在**不同字段**上——共用一个字段就会互相覆盖
|
||||
assert.notEqual(parsePerExtendedNumberingRules(buf).phnum, U16(buf, 60));
|
||||
});
|
||||
|
||||
test('ELF 魔数的字节序:字节 7f 45 4c 46,小端 u32 得 0x464c457f、大端得 0x7f454c46', () => {
|
||||
const buf = buildElfWithExtendedNumbering();
|
||||
assert.equal(buf.subarray(0, 4).toString('hex'), '7f454c46');
|
||||
assert.equal(buf.readUInt32LE(0), 0x464c457f);
|
||||
assert.equal(buf.readUInt32BE(0), 0x7f454c46);
|
||||
});
|
||||
|
||||
test('readelf 对同一 fixture 的解读与三条规则一致(有 readelf 时才跑)', () => {
|
||||
let has = true;
|
||||
try { execFileSync('readelf', ['--version'], { stdio: 'ignore' }); } catch { has = false; }
|
||||
if (!has) return; // 无 binutils 时跳过:本测试的价值是"用独立实现交叉验证我们自己写的解析"
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), 'elffx-'));
|
||||
const p = join(dir, 'ext.elf');
|
||||
writeFileSync(p, buildElfWithExtendedNumbering({ phnum: 7, shnum: 11, shstrndx: 5 }));
|
||||
// 固定 LC_ALL=C:本地化的 readelf 会把字段名译成"程序头数量:"等,正则匹配不到
|
||||
const out = execFileSync('readelf', ['-h', p], { encoding: 'utf8', env: { ...process.env, LC_ALL: 'C' } });
|
||||
// readelf 的显示约定:**原始哨兵值 (括号内为按规则解析出的真值)**,如 `Number of program headers: 65535 (7)`
|
||||
const resolved = (label) => {
|
||||
const m = new RegExp(`${label}:\\s+(\\d+) \\((\\d+)\\)`).exec(out);
|
||||
return m ? { raw: Number(m[1]), real: Number(m[2]) } : null;
|
||||
};
|
||||
assert.deepEqual(resolved('Number of program headers'), { raw: 0xffff, real: 7 });
|
||||
assert.deepEqual(resolved('Number of section headers'), { raw: 0, real: 11 });
|
||||
assert.deepEqual(resolved('Section header string table index'), { raw: 0xffff, real: 5 });
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// 对应 re-flutter/SKILL.md:Dart 的两个 magic 都是**数值**,文件里是小端字节——不是可 grep 的 ASCII 串
|
||||
test('Dart kernel magic 0x90abcdef 与 snapshot magic 0xdcdcf5f5 的字节形态', () => {
|
||||
const kernel = Buffer.alloc(4);
|
||||
kernel.writeUInt32LE(0x90abcdef);
|
||||
assert.equal(kernel.toString('hex'), 'efcdab90');
|
||||
assert.notEqual(kernel.toString('latin1'), 'KERNEL');
|
||||
|
||||
const snap = Buffer.alloc(4);
|
||||
snap.writeUInt32LE(0xdcdcf5f5);
|
||||
assert.equal(snap.toString('hex'), 'f5f5dcdc');
|
||||
assert.ok(!snap.toString('latin1').includes('SNAPSHOT'));
|
||||
});
|
||||
|
||||
test('Dart snapshot 基础头为 20 字节:magic(4) + length(8) + kind(8)', () => {
|
||||
const head = Buffer.alloc(20);
|
||||
head.writeUInt32LE(0xdcdcf5f5, 0);
|
||||
head.writeBigInt64LE(0x1000n, 4); // length
|
||||
head.writeBigInt64LE(2n, 12); // kind = full-aot
|
||||
assert.equal(head.readUInt32LE(0), 0xdcdcf5f5);
|
||||
assert.equal(Number(head.readBigInt64LE(4)), 0x1000);
|
||||
assert.equal(Number(head.readBigInt64LE(12)), 2);
|
||||
assert.equal(head.length, 20);
|
||||
// 头部之后才是(版本相关的)子 blob 布局——本 fixture 只锁定基础头,不对其后的内部布局作承诺
|
||||
});
|
||||
|
||||
// 对应 re-format-macho/references/layout.md:Mach-O 的基础常量与端序规则
|
||||
// 取值对照 LLVM BinaryFormat/MachO.h(Apple loader.h / machine.h 的忠实移植)
|
||||
test('Mach-O cputype / cpusubtype:arm64e 靠 subtype 区分,不靠 cputype', () => {
|
||||
const CPU_ARCH_ABI64 = 0x01000000;
|
||||
const CPU_TYPE_X86 = 7, CPU_TYPE_ARM = 12;
|
||||
assert.equal(CPU_TYPE_X86 | CPU_ARCH_ABI64, 0x01000007); // x86_64
|
||||
assert.equal(CPU_TYPE_ARM | CPU_ARCH_ABI64, 0x0100000c); // arm64
|
||||
// 两个都是 arm64 的二进制,只靠 cpusubtype 分开
|
||||
const ARM64_ALL = 0, ARM64_V8 = 1, ARM64E = 2;
|
||||
assert.deepEqual([ARM64_ALL, ARM64_V8, ARM64E], [0, 1, 2]);
|
||||
// x86_64 侧:3 是 ALL、8 才是 H;0x80000000 只是 capability 位,不改变基础 subtype
|
||||
assert.equal(3, 3); // CPU_SUBTYPE_X86_64_ALL
|
||||
assert.equal(8, 8); // CPU_SUBTYPE_X86_64_H
|
||||
// 注意 JS/C 共同的坑:按位或按有符号 32 位解释,0x80000000|3 会是负数——要 >>>0 才得 uint32
|
||||
assert.equal((0x80000000 | 3) >>> 0, 0x80000003); // LIB64 capability 位
|
||||
assert.ok(0x80000000 | 3 < 0); // 未修正时的真实取值
|
||||
assert.notEqual((0x80000000 | 3) >>> 0, 8); // 它变不出 x86_64h
|
||||
});
|
||||
|
||||
test('Mach-O header flags 是位值,不是连续编号', () => {
|
||||
const MH = { NOUNDEFS: 0x1, INCRLINK: 0x2, DYLDLINK: 0x4, BINDATLOAD: 0x8, SPLIT_SEGS: 0x20, LAZY_INIT: 0x40, TWOLEVEL: 0x80, PIE: 0x200000, HAS_TLV_DESCRIPTORS: 0x800000 };
|
||||
assert.equal(MH.LAZY_INIT, 0x40);
|
||||
assert.equal(MH.TWOLEVEL, 0x80);
|
||||
// 若按"连续编号"手抄,会得到 2=DYLDLINK、4=TWOLEVEL、0x800000=LAZY_INIT —— 全部错位
|
||||
assert.notEqual(MH.DYLDLINK, 0x2);
|
||||
assert.notEqual(MH.TWOLEVEL, 0x4);
|
||||
assert.notEqual(MH.HAS_TLV_DESCRIPTORS, MH.LAZY_INIT);
|
||||
// 独立位可直接按位与判定
|
||||
assert.equal(0x200000 & MH.PIE, MH.PIE);
|
||||
});
|
||||
|
||||
test('Mach-O n_type:基础类型与属性位分层读,0x0f 不是 undefined', () => {
|
||||
const N_TYPE = 0x0e, N_EXT = 0x01, N_PEXT = 0x10;
|
||||
assert.equal(0x0f & N_TYPE, 0x0e); // N_SECT
|
||||
assert.equal(0x0f & N_EXT, 0x01); // 且是 external
|
||||
// 0x0f 应解读为 N_SECT|N_EXT,而不是 N_UNDF
|
||||
assert.notEqual(0x0f & N_TYPE, 0x00);
|
||||
assert.equal(0xf0 & N_TYPE, 0x00); // N_UNDF 只可能是低 4 位为 0 的组合
|
||||
assert.equal(0x01 & N_TYPE, 0);
|
||||
});
|
||||
|
||||
test('fat Mach-O:结构恒为大端,align 是 2 的幂', () => {
|
||||
const header = Buffer.alloc(8);
|
||||
header.writeUInt32BE(0xcafebabe, 0); // FAT_MAGIC,按磁盘约定写大端
|
||||
header.writeUInt32BE(2, 4); // nfat_arch
|
||||
assert.equal(header.subarray(0, 4).toString('hex'), 'cafebabe'); // 文件开头就是 CA FE BA BE
|
||||
assert.equal(header.readUInt32BE(0), 0xcafebabe); // FAT_MAGIC
|
||||
assert.equal(header.readUInt32LE(0), 0xbebafeca); // 小端读得 FAT_CIGAM——同一份字节,不是"小端文件"
|
||||
|
||||
// fat_arch.align 是 2 的幂:align=14 → 偏移需 2^14 对齐,而不是固定 4 字节
|
||||
const align = 14;
|
||||
assert.equal(2 ** align, 16384);
|
||||
assert.equal(16384 % 4, 0); // 2^align 必然满足 4 字节,反之不成立
|
||||
assert.notEqual(2 ** align, 4);
|
||||
});
|
||||
|
||||
// 对应 re-ai-model/SKILL.md:Safetensors 前 8 字节 = 小端 u64 头长度
|
||||
test('Safetensors:前 8 字节是小端 u64 的 JSON 头长度', () => {
|
||||
const header = Buffer.from(JSON.stringify({ __metadata__: { format: 'pt' } }), 'utf8');
|
||||
const len = Buffer.alloc(8);
|
||||
len.writeBigUInt64LE(BigInt(header.length));
|
||||
const file = Buffer.concat([len, header]);
|
||||
|
||||
const readLen = Number(file.readBigUInt64LE(0));
|
||||
assert.equal(readLen, header.length);
|
||||
assert.deepEqual(JSON.parse(file.subarray(8, 8 + readLen).toString('utf8')), { __metadata__: { format: 'pt' } });
|
||||
// 大端读法会得到完全不同的值——这正是"必须按小端读"这条断言的意义
|
||||
assert.notEqual(file.readBigUInt64BE(0), BigInt(header.length));
|
||||
});
|
||||
82
vendor-new/rev-skills/tests/frontmatter.test.mjs
Normal file
82
vendor-new/rev-skills/tests/frontmatter.test.mjs
Normal file
@@ -0,0 +1,82 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { parseFrontmatterFields, splitFrontmatter } from '../lib/frontmatter.mjs';
|
||||
import { parseFrontmatter } from '../validate.mjs';
|
||||
import { readSkill } from '../bin/convert.mjs';
|
||||
|
||||
const SKILLS = join(dirname(fileURLToPath(import.meta.url)), '..', '.claude', 'skills');
|
||||
|
||||
test('折叠块标量 > 合并为单行', () => {
|
||||
const md = '---\nname: re-x\ndescription: >\n 第一行\n 第二行\n---\n\n# 体';
|
||||
assert.equal(parseFrontmatter(md).description, '第一行 第二行');
|
||||
});
|
||||
|
||||
test('字面块标量 | 保留换行', () => {
|
||||
const md = '---\nname: re-x\ndescription: |\n 第一行\n 第二行\n---\n\n# 体';
|
||||
assert.equal(parseFrontmatter(md).description, '第一行\n第二行');
|
||||
});
|
||||
|
||||
test('空块标量按缺描述报错(校验不再是空转)', () => {
|
||||
assert.throws(() => parseFrontmatter('---\nname: re-x\ndescription: >\n---\n\n# 体'), /description/);
|
||||
});
|
||||
|
||||
test('块标量吸收缩进行,不吞并下一个同级键', () => {
|
||||
const fm = parseFrontmatterFields('description: >\n 正文\n body\ncapabilities: [a, b]');
|
||||
assert.equal(fm.description, '正文 body');
|
||||
assert.equal(fm.capabilities, '[a, b]');
|
||||
});
|
||||
|
||||
test('顶格游离行报错,不静默丢弃(块标量误写列表项回归)', () => {
|
||||
// 行号以 frontmatter 块内 1-based 计(对应 SKILL.md 文件行号 +1,块首为文件第 2 行)
|
||||
const md = '---\nname: re-x\ndescription: >\n 正文\n- 游离行\n 触发词:x\n---\n\n# 体';
|
||||
assert.throws(() => parseFrontmatter(md), /unparsable frontmatter line 4.*游离行/);
|
||||
});
|
||||
|
||||
test('re-mobile description 覆盖块标量全部续行(截断回归)', () => {
|
||||
const md = readFileSync(join(SKILLS, 're-mobile', 'SKILL.md'), 'utf8');
|
||||
assert.match(parseFrontmatter(md).description, /加密体系审计/);
|
||||
});
|
||||
|
||||
test('splitFrontmatter 缺 frontmatter 报错并保留正文', () => {
|
||||
assert.throws(() => splitFrontmatter('# 无 frontmatter'), /frontmatter/);
|
||||
assert.equal(splitFrontmatter('---\na: b\n---\n\n正文').body, '正文');
|
||||
});
|
||||
|
||||
test('validate 与 convert 对全部技能解析一致(防解析器漂移回归)', () => {
|
||||
const dirs = readdirSync(SKILLS).filter(d => d.startsWith('re-'));
|
||||
assert.ok(dirs.length >= 46, '技能目录数异常');
|
||||
for (const name of dirs) {
|
||||
const dir = join(SKILLS, name);
|
||||
const md = readFileSync(join(dir, 'SKILL.md'), 'utf8');
|
||||
const v = parseFrontmatter(md);
|
||||
const c = readSkill(dir);
|
||||
assert.equal(v.name, c.name, `${name}: name 不一致`);
|
||||
assert.equal(v.description, c.description, `${name}: description 不一致`);
|
||||
assert.equal(v.body, c.body, `${name}: body 不一致`);
|
||||
assert.ok(v.description.trim().length > 0, `${name}: description 解析为空`);
|
||||
}
|
||||
});
|
||||
|
||||
test('frontmatter 键接受连字符(DSH 的 disable-model-invocation / user-invocable)', () => {
|
||||
// 键文法若只认 \w+,这两个 DSH 合法键会被当成顶格游离行而整份技能报错。
|
||||
const fm = parseFrontmatterFields('name: re-x\ndescription: d\ndisable-model-invocation: false\nuser-invocable: true');
|
||||
assert.equal(fm.name, 're-x');
|
||||
assert.equal(fm['disable-model-invocation'], 'false');
|
||||
assert.equal(fm['user-invocable'], 'true');
|
||||
});
|
||||
|
||||
test('CRLF 检出解析结果与 LF 一致(Windows 行尾回归)', () => {
|
||||
// Windows 上 core.autocrlf=true 的检出会把提交时的 LF 变成 CRLF;
|
||||
// 若不归一,分界正则不匹配 → 整库报 missing frontmatter,能力注册表也解析失败。
|
||||
const lf = '---\nname: re-x\ndescription: >\n 第一行\n 第二行\ncapabilities: [a, b]\n---\n\n# 体';
|
||||
const crlf = lf.replace(/\n/g, '\r\n');
|
||||
assert.equal(parseFrontmatter(crlf).description, parseFrontmatter(lf).description);
|
||||
assert.equal(parseFrontmatter(crlf).body, parseFrontmatter(lf).body);
|
||||
assert.deepEqual(parseFrontmatterFields(splitFrontmatter(crlf).raw), parseFrontmatterFields(splitFrontmatter(lf).raw));
|
||||
assert.equal(splitFrontmatter(crlf).body, '# 体');
|
||||
// 单个 CR 行尾(旧 Mac 风格)同样归一
|
||||
assert.equal(parseFrontmatter(lf.replace(/\n/g, '\r')).name, 're-x');
|
||||
});
|
||||
76
vendor-new/rev-skills/tests/install.test.mjs
Normal file
76
vendor-new/rev-skills/tests/install.test.mjs
Normal file
@@ -0,0 +1,76 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, existsSync, readFileSync, readdirSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { planFor, SKILLS_SRC } from '../bin/install.mjs';
|
||||
|
||||
const INSTALLER = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'install.mjs');
|
||||
|
||||
test('planFor: claude global 使用 HOME 展开', () => {
|
||||
const old = process.env.HOME;
|
||||
process.env.HOME = '/tmp/fakehome';
|
||||
const plan = planFor('claude', 'global', {});
|
||||
assert.equal(plan.dest, '/tmp/fakehome/.claude/skills');
|
||||
assert.equal(plan.mode, 'native');
|
||||
process.env.HOME = old;
|
||||
});
|
||||
|
||||
test('planFor: cursor 是 rule 模式', () => {
|
||||
const plan = planFor('cursor', 'project', {});
|
||||
assert.equal(plan.mode, 'rule');
|
||||
assert.equal(plan.ruleType, 'cursor');
|
||||
});
|
||||
|
||||
test('planFor: cline 不支持 --global', () => {
|
||||
assert.throws(() => planFor('cline', 'global', {}), /does not support/);
|
||||
});
|
||||
|
||||
test('SKILLS_SRC 存在且含 re- 技能', () => {
|
||||
const names = readdirSync(SKILLS_SRC);
|
||||
assert.ok(names.includes('re-analyze'));
|
||||
assert.ok(names.length >= 46);
|
||||
});
|
||||
|
||||
// DSH 技能根:全局 <dshHome>/skills(~/.dsh/skills),项目 .dsh/skills
|
||||
test('planFor: dsh global 落在 DSH 家目录的技能根', () => {
|
||||
const old = process.env.HOME;
|
||||
process.env.HOME = '/tmp/fakehome';
|
||||
try {
|
||||
const plan = planFor('dsh', 'global', {});
|
||||
assert.equal(plan.mode, 'native');
|
||||
assert.equal(plan.dest, '/tmp/fakehome/.dsh/skills');
|
||||
assert.ok(plan.names.includes('re-analyze'));
|
||||
} finally {
|
||||
process.env.HOME = old;
|
||||
}
|
||||
});
|
||||
|
||||
test('planFor: dsh project 落在 .dsh/skills', () => {
|
||||
const plan = planFor('dsh', 'project', {});
|
||||
assert.equal(plan.mode, 'native');
|
||||
assert.equal(plan.dest, '.dsh/skills');
|
||||
});
|
||||
|
||||
// `--target all --global` 跳过只有 --project 的目标;dry-run 保证不落盘。
|
||||
// 顺带看住自检提示是按目标取词:dsh 给 DSH 口径,claude 保持原样。
|
||||
test('CLI: --target all --global --dry-run 跳过规则型目标并包含 dsh', () => {
|
||||
const out = execFileSync(process.execPath, [INSTALLER, '--target', 'all', '--global', '--dry-run', '--yes'], { encoding: 'utf8' });
|
||||
for (const t of ['cline', 'cursor', 'copilot', 'windsurf']) {
|
||||
assert.match(out, new RegExp(`\\[${t}\\] skip:`), `${t} 没有全局落点,应被跳过`);
|
||||
}
|
||||
const count = readdirSync(SKILLS_SRC).length;
|
||||
assert.ok(out.includes(`[dsh] plan: ${count} skills`), `dsh 应参与 --target all --global:\n${out}`);
|
||||
assert.match(out, /\[dsh\] verify: 技能根 .*\.dsh[\\/]skills;新建会话后 `skill` 工具目录可见/);
|
||||
assert.match(out, /\[claude\] verify: Claude Code 中运行 \/skills 查看/);
|
||||
});
|
||||
|
||||
test('CLI: --target dsh --project --dry-run 只打印计划', () => {
|
||||
// 在临时目录里跑,避免工作区里已有的 .dsh 让「dry-run 不落盘」这条断言假阴/假阳
|
||||
const cwd = mkdtempSync(join(tmpdir(), 'rs-install-'));
|
||||
const out = execFileSync(process.execPath, [INSTALLER, '--target', 'dsh', '--project', '--dry-run'], { encoding: 'utf8', cwd });
|
||||
assert.match(out, /\[dsh\] plan: \d+ skills → \.dsh[\\/]skills/);
|
||||
assert.ok(!existsSync(join(cwd, '.dsh')), 'dry-run 不应创建 .dsh 目录');
|
||||
});
|
||||
75
vendor-new/rev-skills/tests/review-state.test.mjs
Normal file
75
vendor-new/rev-skills/tests/review-state.test.mjs
Normal file
@@ -0,0 +1,75 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { hashSkill, computeStatus, markReviewed, skillDirs } from '../lib/review-state.mjs';
|
||||
|
||||
function fakeRepo({ reviewed = [], date = '2026-01-01' } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'rs-'));
|
||||
mkdirSync(join(root, 'docs', 'audit'), { recursive: true });
|
||||
mkdirSync(join(root, '.claude', 'skills', 're-a'), { recursive: true });
|
||||
mkdirSync(join(root, '.claude', 'skills', 're-b'), { recursive: true });
|
||||
writeFileSync(join(root, '.claude', 'skills', 're-a', 'SKILL.md'), '# a\n');
|
||||
writeFileSync(join(root, '.claude', 'skills', 're-b', 'SKILL.md'), '# b\n');
|
||||
if (reviewed.length) markReviewed(root, join(root, '.claude', 'skills'), reviewed, { date });
|
||||
return root;
|
||||
}
|
||||
|
||||
test('hashSkill 随内容变化,且与文件顺序无关', () => {
|
||||
const root = fakeRepo();
|
||||
const dir = join(root, '.claude', 'skills', 're-a');
|
||||
const h1 = hashSkill(dir);
|
||||
writeFileSync(join(dir, 'SKILL.md'), '# a changed\n');
|
||||
const h2 = hashSkill(dir);
|
||||
assert.notEqual(h1, h2);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('computeStatus:未复核 / 已复核未变 / 内容又变 三态', () => {
|
||||
const root = fakeRepo({ reviewed: ['re-a'] });
|
||||
const skills = join(root, '.claude', 'skills');
|
||||
let s = computeStatus(root, skills);
|
||||
assert.deepEqual(s.ok.map((x) => x.name), ['re-a']);
|
||||
assert.deepEqual(s.unreviewed.map((x) => x.name), ['re-b']);
|
||||
|
||||
writeFileSync(join(skills, 're-a', 'SKILL.md'), '# a v2\n');
|
||||
s = computeStatus(root, skills);
|
||||
assert.deepEqual(s.changed.map((x) => x.name), ['re-a']);
|
||||
assert.equal(s.ok.length, 0);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('markReviewed 回填后转为"已复核未变"', () => {
|
||||
const root = fakeRepo();
|
||||
const skills = join(root, '.claude', 'skills');
|
||||
markReviewed(root, skills, ['re-a', 're-b'], { date: '2026-09-13' });
|
||||
const s = computeStatus(root, skills);
|
||||
assert.equal(s.ok.length, 2);
|
||||
assert.equal(s.unreviewed.length, 0);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('markReviewed 拒绝未知技能', () => {
|
||||
const root = fakeRepo();
|
||||
assert.throws(() => markReviewed(root, join(root, '.claude', 'skills'), ['re-nope']), /未知技能/);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('已删除技能会从状态里清理并在 status 中报警', () => {
|
||||
const root = fakeRepo({ reviewed: ['re-a', 're-b'] });
|
||||
const skills = join(root, '.claude', 'skills');
|
||||
rmSync(join(skills, 're-b'), { recursive: true, force: true });
|
||||
const s = computeStatus(root, skills);
|
||||
assert.deepEqual(s.unknown, ['re-b']);
|
||||
markReviewed(root, skills, ['re-a'], { date: '2026-09-14' });
|
||||
assert.deepEqual(computeStatus(root, skills).unknown, []);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('仓库自身:状态文件覆盖全部技能(无缺失、无多余)', () => {
|
||||
const names = skillDirs('.claude/skills');
|
||||
const { unreviewed, ok, changed, unknown } = computeStatus('.', '.claude/skills');
|
||||
assert.deepEqual(unknown, [], '状态里有已不存在的技能');
|
||||
assert.equal(unreviewed.length + ok.length + changed.length, names.length);
|
||||
});
|
||||
55
vendor-new/rev-skills/tests/skill-budget.test.mjs
Normal file
55
vendor-new/rev-skills/tests/skill-budget.test.mjs
Normal file
@@ -0,0 +1,55 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
||||
|
||||
// 技能规模预算:SKILL.md 是**每次都会加载**的部分,references 是按需加载的。
|
||||
// 所以真正需要设防的是 SKILL.md 膨胀成单体文档——references 再厚也只是"书架上多几本"。
|
||||
const SKILLS = '.claude/skills';
|
||||
const SKILL_MD_BUDGET = 240; // 现行最大 210(re-iot-proto);留一成余量,超出即提示拆分
|
||||
const BRANCH_BUDGET = 600; // 单个 references 文件的上限(现行最大 506:windows-kernel.md)
|
||||
|
||||
function lines(p) {
|
||||
return readFileSync(p, 'utf8').split('\n').length;
|
||||
}
|
||||
|
||||
test(`SKILL.md 行数不超过 ${SKILL_MD_BUDGET}(超出应把细节下沉到 references)`, () => {
|
||||
const over = [];
|
||||
for (const d of readdirSync(SKILLS)) {
|
||||
const p = `${SKILLS}/${d}/SKILL.md`;
|
||||
if (!d.startsWith('re-') || !existsSync(p)) continue;
|
||||
const n = lines(p);
|
||||
if (n > SKILL_MD_BUDGET) over.push(`${d}(${n} 行)`);
|
||||
}
|
||||
assert.deepEqual(over, [], `SKILL.md 超预算——把机制细节移到 references/ 分支:\n${over.join('\n')}`);
|
||||
});
|
||||
|
||||
test(`单个 references 分支不超过 ${BRANCH_BUDGET} 行`, () => {
|
||||
const over = [];
|
||||
for (const d of readdirSync(SKILLS)) {
|
||||
const dir = `${SKILLS}/${d}/references`;
|
||||
if (!d.startsWith('re-') || !existsSync(dir)) continue;
|
||||
for (const f of readdirSync(dir)) {
|
||||
if (!f.endsWith('.md')) continue;
|
||||
const n = lines(`${dir}/${f}`);
|
||||
if (n > BRANCH_BUDGET) over.push(`${d}/references/${f}(${n} 行)`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(over, [], `分支超长——按主题再切一层:\n${over.join('\n')}`);
|
||||
});
|
||||
|
||||
// 反向保护:预算是"拆分信号"而不是"越短越好"——SKILL.md 必须仍然自带工具准备与操作步骤,
|
||||
// 否则就是被削成了空壳(把内容全推给 references 同样会让 agent 无从下手)。
|
||||
test('SKILL.md 仍自带必备章节(防止为了满足预算而削壳)', () => {
|
||||
const missing = [];
|
||||
for (const d of readdirSync(SKILLS)) {
|
||||
const p = `${SKILLS}/${d}/SKILL.md`;
|
||||
if (!d.startsWith('re-') || !existsSync(p)) continue;
|
||||
const text = readFileSync(p, 'utf8');
|
||||
const type = /^type:\s*(\w+)/m.exec(text)?.[1] ?? 'atomic';
|
||||
if (type !== 'atomic') continue; // 入口/网关另有一套章节规范
|
||||
for (const sec of ['## 何时使用', '## 操作步骤']) {
|
||||
if (!text.includes(sec)) missing.push(`${d}: ${sec}`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(missing, [], `原子技能缺少必备章节:\n${missing.join('\n')}`);
|
||||
});
|
||||
120
vendor-new/rev-skills/tests/toollife.test.mjs
Normal file
120
vendor-new/rev-skills/tests/toollife.test.mjs
Normal file
@@ -0,0 +1,120 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { extractCommands, checkRegister, daysSince } from '../lib/tool-register.mjs';
|
||||
|
||||
test('extractCommands 只认 shell 标注块,未标注块不误抓', () => {
|
||||
const md = [
|
||||
'```sh',
|
||||
'readelf -h a.out',
|
||||
'sudo tshark -i eth0',
|
||||
'```',
|
||||
'',
|
||||
'```python',
|
||||
'import onnx',
|
||||
'print("hi")',
|
||||
'```',
|
||||
'',
|
||||
'```',
|
||||
'import struct',
|
||||
'Def = 1',
|
||||
'```',
|
||||
].join('\n');
|
||||
const cmds = extractCommands(md);
|
||||
assert.deepEqual(cmds, ['readelf', 'tshark']);
|
||||
});
|
||||
|
||||
test('extractCommands 跳过 heredoc 体、注释与 shell 关键字', () => {
|
||||
const md = [
|
||||
'```bash',
|
||||
'# comment',
|
||||
'cat > x.txt <<EOF',
|
||||
'readelf notacommand',
|
||||
'EOF',
|
||||
'for i in 1 2; do echo $i; done',
|
||||
'objdump -d a.out',
|
||||
'```',
|
||||
].join('\n');
|
||||
assert.deepEqual(extractCommands(md), ['objdump']);
|
||||
});
|
||||
|
||||
test('extractCommands 去掉环境变量前缀与提示符', () => {
|
||||
const md = '```console\n$ FOO=1 gdb ./a.out\n> otool -l x\n```\n';
|
||||
assert.deepEqual(extractCommands(md), ['gdb', 'otool']);
|
||||
});
|
||||
|
||||
test('extractCommands 过滤基础工具(不登记 coreutils/包管理器)', () => {
|
||||
const md = '```sh\nls -l\ncat f\napt install x\ngit log\n```\n';
|
||||
assert.deepEqual(extractCommands(md), []);
|
||||
});
|
||||
|
||||
// 造一个最小仓库:一个技能引用 readelf,登记表内容由参数决定
|
||||
function fakeRepo({ register }) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'toollife-'));
|
||||
mkdirSync(join(root, 'docs', 'audit'), { recursive: true });
|
||||
mkdirSync(join(root, '.claude', 'skills', 're-x'), { recursive: true });
|
||||
writeFileSync(join(root, '.claude', 'skills', 're-x', 'SKILL.md'),
|
||||
'---\nname: re-x\ndescription: 测试技能。test skill.\ncapabilities: [kernel-analysis]\n---\n\n# t\n\n```sh\nreadelf -h a\n```\n');
|
||||
writeFileSync(join(root, 'docs', 'audit', 'tool-register.json'), JSON.stringify(register, null, 2));
|
||||
return root;
|
||||
}
|
||||
|
||||
test('死条目报错:登记在用但技能里已找不到', () => {
|
||||
const root = fakeRepo({ register: { tools: [{ name: 'objdump', kind: 'cli', where: ['re-x'], last_verified: null, source: '' }] } });
|
||||
const { errors, candidates } = checkRegister(root, join(root, '.claude', 'skills'));
|
||||
assert.ok(errors.some((e) => e.includes('死条目')));
|
||||
assert.deepEqual(candidates, ['readelf']);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('登记齐备时无错,且 ignore 名单内的候选不报', () => {
|
||||
const root = fakeRepo({
|
||||
register: {
|
||||
tools: [{ name: 'readelf', kind: 'cli', where: ['re-x'], last_verified: '2026-09-13', source: 'man page' }],
|
||||
ignore: [],
|
||||
},
|
||||
});
|
||||
const { errors, candidates } = checkRegister(root, join(root, '.claude', 'skills'));
|
||||
assert.deepEqual(errors, []);
|
||||
assert.deepEqual(candidates, []);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('last_verified 允许 null(待核验积压),但非法日期报错', () => {
|
||||
const ok = fakeRepo({ register: { tools: [{ name: 'readelf', kind: 'cli', where: [], last_verified: null, source: '' }] } });
|
||||
assert.deepEqual(checkRegister(ok, join(ok, '.claude', 'skills')).errors, []);
|
||||
rmSync(ok, { recursive: true, force: true });
|
||||
|
||||
const bad = fakeRepo({ register: { tools: [{ name: 'readelf', kind: 'cli', where: [], last_verified: '2026/09/13', source: '' }] } });
|
||||
assert.ok(checkRegister(bad, join(bad, '.claude', 'skills')).errors.some((e) => e.includes('last_verified')));
|
||||
rmSync(bad, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('时效:只统计已核验的超期项,未核验的进积压', () => {
|
||||
const root = fakeRepo({
|
||||
register: {
|
||||
tools: [
|
||||
{ name: 'readelf', kind: 'cli', where: ['re-x'], last_verified: '2020-01-01', source: 'x' },
|
||||
{ name: 'objdump', kind: 'cli', where: [], last_verified: null, source: '' },
|
||||
],
|
||||
ignore: [],
|
||||
},
|
||||
});
|
||||
const { stale, pending } = checkRegister(root, join(root, '.claude', 'skills'), { staleDays: 180 });
|
||||
assert.deepEqual(stale.map((s) => s.name), ['readelf']);
|
||||
assert.deepEqual(pending, ['objdump']);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('仓库自身的登记表一致(无死条目、无未登记候选)', () => {
|
||||
const { errors, candidates } = checkRegister('.', '.claude/skills');
|
||||
assert.deepEqual(errors, []);
|
||||
assert.deepEqual(candidates, [], `未登记候选:${candidates.join(' ')}`);
|
||||
});
|
||||
|
||||
test('daysSince 对非法日期返回 Infinity', () => {
|
||||
assert.equal(daysSince('not-a-date'), Infinity);
|
||||
assert.ok(daysSince('2026-09-13') >= 0);
|
||||
});
|
||||
161
vendor-new/rev-skills/tests/validate.test.mjs
Normal file
161
vendor-new/rev-skills/tests/validate.test.mjs
Normal file
@@ -0,0 +1,161 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { parseFrontmatter, checkSkillDir, collectSkills, SKILL_PREFIX } from '../validate.mjs';
|
||||
|
||||
// Windows 下 URL.pathname 产出 /D:/... 形式,不能直接当本地路径;用 fileURLToPath 统一转换
|
||||
const FIX = fileURLToPath(new URL('./fixtures/', import.meta.url));
|
||||
|
||||
test('parseFrontmatter 解析合法 frontmatter', () => {
|
||||
const md = `---\nname: re-abc\ndescription: 测试。\n---\n\n# 标题\n\n正文`;
|
||||
const { name, description, body } = parseFrontmatter(md);
|
||||
assert.equal(name, 're-abc');
|
||||
assert.equal(description, '测试。');
|
||||
assert.match(body, /^# 标题/);
|
||||
});
|
||||
|
||||
test('parseFrontmatter 拒绝缺失 name/description', () => {
|
||||
assert.throws(() => parseFrontmatter('---\nname: x\n---\n'), /description/);
|
||||
assert.throws(() => parseFrontmatter('# no frontmatter'), /frontmatter/);
|
||||
});
|
||||
|
||||
// 校验用例共用的能力注册表子集(原子技能必须声明能力,声明值须在注册表内)
|
||||
const CAPS = { knownCapabilities: new Set(['triage']) };
|
||||
|
||||
test('good-skill 通过全部检查', () => {
|
||||
const { errors } = checkSkillDir(FIX + 'good-skill', CAPS);
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
|
||||
test('bad-name 报名字不一致错误', () => {
|
||||
const { errors } = checkSkillDir(FIX + 'bad-name');
|
||||
assert.ok(errors.some(e => e.includes('name') && e.includes('wrong-name')));
|
||||
});
|
||||
|
||||
test('no-tools 叶子技能报缺工具准备', () => {
|
||||
const { errors } = checkSkillDir(FIX + 'no-tools');
|
||||
assert.ok(errors.some(e => e.includes('工具准备')));
|
||||
});
|
||||
|
||||
test('bad-frontmatter 顶格游离行报错(校验闸口不再放过非法 frontmatter)', () => {
|
||||
const { errors } = checkSkillDir(FIX + 'bad-frontmatter');
|
||||
assert.ok(errors.some(e => e.includes('frontmatter line')), `未报错:${JSON.stringify(errors)}`);
|
||||
});
|
||||
|
||||
test('链接规则:技能死链与跨技能裸引用报错,本技能 references 放行', () => {
|
||||
const { errors } = checkSkillDir(FIX + 'broken-link', { knownSkills: ['re-abc'] });
|
||||
assert.ok(errors.some(e => e.includes('re-does-not-exist')), '技能死链应报错');
|
||||
assert.ok(errors.some(e => e.includes('platform-tips')), '跨技能裸引用应报错');
|
||||
assert.ok(!errors.some(e => e.includes('local-note')), '本技能 references 链接应放行');
|
||||
});
|
||||
|
||||
test('跨技能 references 用 [[re-xxx/name]] 限定后放行,指向不存在则报错', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'link-'));
|
||||
const mkSkill = (skill, body, refs = {}) => {
|
||||
mkdirSync(join(root, skill, 'references'), { recursive: true });
|
||||
writeFileSync(join(root, skill, 'SKILL.md'),
|
||||
`---\nname: ${skill}\ndescription: 测试。test。\ncapabilities: [triage]\n---\n\n# 标题\n\n## 工具准备\n\n${body}`);
|
||||
for (const [n, c] of Object.entries(refs)) writeFileSync(join(root, skill, 'references', `${n}.md`), c);
|
||||
};
|
||||
const opts = { ...CAPS, knownSkills: ['re-a', 're-b'] };
|
||||
try {
|
||||
mkSkill('re-a', '跨技能见 [[re-b/shared]]');
|
||||
mkSkill('re-b', '本技能内见 [[shared]]', { shared: 'x' });
|
||||
assert.deepEqual(checkSkillDir(join(root, 're-a'), opts).errors, []);
|
||||
assert.deepEqual(checkSkillDir(join(root, 're-b'), opts).errors, []);
|
||||
mkSkill('re-a', '跨技能见 [[re-b/nope]]');
|
||||
assert.ok(checkSkillDir(join(root, 're-a'), opts).errors.some(e => e.includes('re-b/nope')));
|
||||
} finally { rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('gateway-skill 豁免工具准备检查', () => {
|
||||
const { errors } = checkSkillDir(FIX + 'gateway-skill');
|
||||
assert.ok(!errors.some(e => e.includes('工具准备')));
|
||||
});
|
||||
|
||||
test('collectSkills 收集全部技能目录名', () => {
|
||||
const names = collectSkills(FIX);
|
||||
assert.deepEqual([...names].sort(), ['bad-frontmatter', 'bad-name', 'broken-link', 'gateway-skill', 'good-skill', 'no-tools']);
|
||||
});
|
||||
|
||||
test('parseFrontmatter 解析 capabilities list', () => {
|
||||
const md = `---\nname: re-abc\ndescription: 测试。\ncapabilities: [elf-parser, unpack]\n---\n\n# 标题\n\n正文`;
|
||||
const { capabilities } = parseFrontmatter(md);
|
||||
assert.deepEqual(capabilities, ['elf-parser', 'unpack']);
|
||||
});
|
||||
|
||||
test('guard 合法 JSON 通过、结构非法报错', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'guard-test-'));
|
||||
const name = dir.split(/[\\/]/).pop();
|
||||
const body = `---\nname: ${name}\ndescription: 测试。test。\ncapabilities: [triage]\nguard: %GUARD%\n---\n\n# 标题\n\n## 工具准备\n\n正文`;
|
||||
try {
|
||||
// 合法
|
||||
writeFileSync(join(dir, 'SKILL.md'), body.replace('%GUARD%', '{"require_authorization": true, "forbidden": ["unauthorized_api_testing"]}'));
|
||||
assert.deepEqual(checkSkillDir(dir, CAPS).errors, []);
|
||||
// 非 JSON
|
||||
writeFileSync(join(dir, 'SKILL.md'), body.replace('%GUARD%', 'require_authorization: true'));
|
||||
const { errors } = checkSkillDir(dir, CAPS);
|
||||
assert.ok(errors.some(e => e.includes('guard must be')));
|
||||
// 结构缺字段
|
||||
writeFileSync(join(dir, 'SKILL.md'), body.replace('%GUARD%', '{"require_authorization": "yes"}'));
|
||||
const { errors: e2 } = checkSkillDir(dir, CAPS);
|
||||
assert.ok(e2.some(e => e.includes('guard must be')));
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('description 须中英双含(触发词约定)', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'desc-i18n-'));
|
||||
const name = dir.split(/[\\/]/).pop();
|
||||
const withDesc = (d) => `---\nname: ${name}\ndescription: ${d}\ncapabilities: [triage]\n---\n\n# 标题\n\n## 工具准备\n\n正文`;
|
||||
try {
|
||||
// 双语俱全 → 通过
|
||||
writeFileSync(join(dir, 'SKILL.md'), withDesc('测试技能:test skill 触发词'));
|
||||
assert.deepEqual(checkSkillDir(dir, CAPS).errors, []);
|
||||
// 仅中文 → 报错(英文环境命中不了)
|
||||
writeFileSync(join(dir, 'SKILL.md'), withDesc('只有中文触发词'));
|
||||
assert.ok(checkSkillDir(dir, CAPS).errors.some(e => e.includes('CJK')));
|
||||
// 仅英文 → 报错
|
||||
writeFileSync(join(dir, 'SKILL.md'), withDesc('english triggers only'));
|
||||
assert.ok(checkSkillDir(dir, CAPS).errors.some(e => e.includes('CJK')));
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('原子技能未声明能力即报错(能力层完整性)', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'no-caps-'));
|
||||
const name = dir.split(/[\\/]/).pop();
|
||||
try {
|
||||
writeFileSync(join(dir, 'SKILL.md'), `---\nname: ${name}\ndescription: 测试。test。\n---\n\n# 标题\n\n## 工具准备\n\n正文`);
|
||||
assert.ok(checkSkillDir(dir, CAPS).errors.some(e => e.includes('must declare capabilities')));
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('capabilities 合法通过、未知标签与非法写法报错', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'cap-test-'));
|
||||
const name = dir.split(/[\\/]/).pop();
|
||||
const caps = new Set(['elf-parser', 'unpack', 'tracing']);
|
||||
const body = `---\nname: ${name}\ndescription: 测试。test。\ncapabilities: %CAPS%\n---\n\n# 标题\n\n## 工具准备\n\n正文`;
|
||||
try {
|
||||
// 合法 list 通过
|
||||
writeFileSync(join(dir, 'SKILL.md'), body.replace('%CAPS%', '[elf-parser, unpack]'));
|
||||
assert.deepEqual(checkSkillDir(dir, { knownCapabilities: caps }).errors, []);
|
||||
// 未知标签报错
|
||||
writeFileSync(join(dir, 'SKILL.md'), body.replace('%CAPS%', '[elf-parser, nope-cap]'));
|
||||
const { errors } = checkSkillDir(dir, { knownCapabilities: caps });
|
||||
assert.ok(errors.some(e => e.includes("unknown capability 'nope-cap'")));
|
||||
// 非法写法(非 list)报错
|
||||
writeFileSync(join(dir, 'SKILL.md'), body.replace('%CAPS%', 'elf-parser'));
|
||||
const { errors: e2 } = checkSkillDir(dir, { knownCapabilities: caps });
|
||||
assert.ok(e2.some(e => e.includes('non-empty list')));
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
102
vendor-new/rev-skills/tests/wxsource.test.mjs
Normal file
102
vendor-new/rev-skills/tests/wxsource.test.mjs
Normal file
@@ -0,0 +1,102 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
htmlToText,
|
||||
parseKanxueHomeList,
|
||||
parseForumList,
|
||||
parseThread,
|
||||
parseWechat,
|
||||
BASE,
|
||||
} from '../bin/wxsource.mjs';
|
||||
|
||||
// 以下片段取自 2026-08 实抓的 bbs.kanxue.com 页面(仅保留关键结构)
|
||||
|
||||
test('htmlToText 转换段落/代码/图片/实体', () => {
|
||||
const html =
|
||||
'<p>第一段 <code>vmwrite(x)</code> 结束</p><p><img src="a.jpg"></p><pre>int x = 1;</pre><p>a&b <c> </p>';
|
||||
const t = htmlToText(html);
|
||||
assert.match(t, /第一段 `vmwrite\(x\)` 结束/);
|
||||
assert.match(t, /\[图片\]/);
|
||||
assert.match(t, /int x = 1;/);
|
||||
assert.match(t, /a&b <c>/);
|
||||
});
|
||||
|
||||
test('parseKanxueHomeList 首页聚合列表', () => {
|
||||
const html = `
|
||||
<a class="bbs_home_page_list_title" href="thread-292402.htm" title="[原创] 我把散装逆向经验压成了一套 Agent 作战系统:r0crawl_skills 全面介绍">[原创] 我把散装逆向经验压成了一套 Agent 作战系统:r0crawl_skills 全面介绍</a>
|
||||
<a class="bbs_home_page_list_title" href="thread-292370.htm" title="[原创]代码虚拟化面对AI时代的冲击即将彻底被击溃">[原创]代码虚拟化面对AI时代的冲击即将彻底被击溃</a>`;
|
||||
const items = parseKanxueHomeList(html);
|
||||
assert.equal(items.length, 2);
|
||||
assert.equal(items[0].url, `${BASE}/thread-292402.htm`);
|
||||
assert.match(items[0].title, /r0crawl_skills/);
|
||||
});
|
||||
|
||||
test('parseForumList 分类页 xiuno 列表', () => {
|
||||
const html = `
|
||||
<table class="table threadlist"><tbody>
|
||||
<tr class="thread top_3" data-tid="292346">
|
||||
<td class="td-subject px-3"><div class="subject">
|
||||
<a href="thread-292346.htm" style="vertical-align: middle;">[公告] 2026 KCTF 攻击方规则发布</a>
|
||||
</div></td>
|
||||
<td><div class="col px-0"><a href="user-home-37853.htm" style="color: #999999;" class="username hidden-sm hidden-md">@ninebell</a></div></td>
|
||||
</tr>
|
||||
<tr class="thread" data-tid="292331">
|
||||
<td class="td-subject px-3"><div class="subject">
|
||||
<a href="thread-292331.htm" style="vertical-align: middle;">[原创]某商业级加固 Native Loader 逆向分析</a>
|
||||
</div></td>
|
||||
<td><div class="col px-0"><a href="user-home-1001878.htm" style="color: #999999;" class="username hidden-sm hidden-md">只会逆一点点</a></div></td>
|
||||
</tr>
|
||||
</tbody></table>`;
|
||||
const items = parseForumList(html);
|
||||
assert.equal(items.length, 2);
|
||||
assert.equal(items[0].title, '[公告] 2026 KCTF 攻击方规则发布');
|
||||
assert.equal(items[0].author, '@ninebell');
|
||||
assert.equal(items[0].url, `${BASE}/thread-292346.htm`);
|
||||
assert.equal(items[1].author, '只会逆一点点');
|
||||
});
|
||||
|
||||
test('parseThread 帖子页标题/作者/正文', () => {
|
||||
const html = `
|
||||
<title>[原创] 实战帖子标题-软件逆向-看雪安全社区|专业技术交流与安全研究论坛</title>
|
||||
<a href="user-home-1001878.htm" title="只会逆一点点">头像</a>
|
||||
<div class="message message_md_type" isfirst="1">
|
||||
<p>正文第一段 <code>mov eax, 0xCC</code></p><p><img src="x.png"></p>
|
||||
</div>
|
||||
<div class="message mt-2 break-all message_rich_type">
|
||||
<p>二楼回帖,不应被提取</p>
|
||||
</div>`;
|
||||
const t = parseThread(html);
|
||||
assert.equal(t.title, '[原创] 实战帖子标题');
|
||||
assert.equal(t.author, '只会逆一点点');
|
||||
assert.match(t.body, /正文第一段 `mov eax, 0xCC`/);
|
||||
assert.match(t.body, /\[图片\]/);
|
||||
assert.doesNotMatch(t.body, /二楼回帖/);
|
||||
});
|
||||
|
||||
test('parseThread 富文本类型正文(message_rich_type)', () => {
|
||||
const html = `
|
||||
<title>[原创] TikTok X-Dynosaur 恐龙算法-逆向工程-看雪安全社区</title>
|
||||
<div class="message message_rich_type" isfirst="1">
|
||||
<p>富文本正文 <b>加粗</b> 内容</p>
|
||||
<p><code>X-Dynosaur</code> 算法分析</p>
|
||||
</div>`;
|
||||
const t = parseThread(html);
|
||||
assert.equal(t.title, '[原创] TikTok X-Dynosaur 恐龙算法');
|
||||
assert.match(t.body, /富文本正文 加粗 内容/);
|
||||
assert.match(t.body, /`X-Dynosaur` 算法分析/);
|
||||
});
|
||||
|
||||
test('parseWechat 微信文章 og meta + js_content', () => {
|
||||
const html = `
|
||||
<meta property="og:title" content="基于VT EPT的无痕断点无痕hook原理及其应用">
|
||||
<meta property="og:article:author" content="只会逆一点点">
|
||||
<div id="js_content" class="rich_media_content">
|
||||
<p>正文第一句</p><p><code>vmexit</code> 触发</p>
|
||||
</div>
|
||||
<script>var t=1;</script>`;
|
||||
const a = parseWechat(html);
|
||||
assert.equal(a.title, '基于VT EPT的无痕断点无痕hook原理及其应用');
|
||||
assert.equal(a.author, '只会逆一点点');
|
||||
assert.match(a.body, /正文第一句/);
|
||||
assert.match(a.body, /`vmexit` 触发/);
|
||||
});
|
||||
Reference in New Issue
Block a user