diff --git a/bin/install.js b/bin/install.js index 69d3a6b..2a823f7 100755 --- a/bin/install.js +++ b/bin/install.js @@ -230,6 +230,12 @@ function scanInvocableSkills(skillsDir) { /** * 根据 SKILL.md 元数据生成 command .md 内容 + * + * 设计原则: + * - 读取 SKILL.md + 执行脚本合并为一气呵成的指令流 + * - 禁止「先…然后…」的分步模式,避免 Claude 在步骤间停顿 + * - 无脚本的 skill:仅读取 SKILL.md 作为知识库提供指导 + * * @param {Object} meta - parseFrontmatter 返回的元数据 * @param {string} skillRelPath - 相对于 skills/ 的路径(如 'tools/gen-docs') * @param {boolean} hasScripts - 是否有可执行脚本 @@ -253,24 +259,23 @@ function generateCommandContent(meta, skillRelPath, hasScripts) { lines.push(`allowed-tools: ${tools}`); lines.push('---'); lines.push(''); - lines.push('## 执行指南'); - lines.push(''); - lines.push('先读取完整的 Skill 定义获取详细规范:'); - lines.push(''); - lines.push('```'); - lines.push(skillPath); - lines.push('```'); if (hasScripts) { + // ── 有脚本的 skill:读取规范 + 执行脚本,一气呵成 ── + lines.push('以下所有步骤一气呵成,不要在步骤间停顿等待用户输入:'); lines.push(''); - lines.push('然后执行:'); + lines.push(`1. 读取规范:${skillPath}`); + lines.push(`2. 执行命令:\`node ~/.claude/skills/run_skill.js ${name} $ARGUMENTS\``); + lines.push('3. 按规范分析输出,完成后续动作'); lines.push(''); - lines.push('```bash'); - lines.push(`node ~/.claude/skills/run_skill.js ${name} $ARGUMENTS`); - lines.push('```'); + lines.push('全程不要停顿,不要询问是否继续。'); } else { + // ── 无脚本的 skill:知识库模式 ── + lines.push('读取以下秘典,根据内容为用户提供专业指导:'); lines.push(''); - lines.push('根据秘典内容为用户提供专业指导。'); + lines.push('```'); + lines.push(skillPath); + lines.push('```'); } lines.push(''); diff --git a/skills/tools/gen-docs/scripts/doc_generator.js b/skills/tools/gen-docs/scripts/doc_generator.js index 7f3823b..b533e9f 100755 --- a/skills/tools/gen-docs/scripts/doc_generator.js +++ b/skills/tools/gen-docs/scripts/doc_generator.js @@ -9,12 +9,66 @@ const path = require('path'); // --- Utilities --- -function rglob(dir, filter) { +function parseGitignore(modPath) { + const patterns = []; + const hardcoded = ['node_modules', '.git', '__pycache__', '.vscode', '.idea', 'dist', 'build', '.DS_Store']; + + // 硬编码常见排除 + hardcoded.forEach(p => patterns.push({ pattern: p, negate: false })); + + // 解析 .gitignore + try { + const gitignorePath = path.join(modPath, '.gitignore'); + const content = fs.readFileSync(gitignorePath, 'utf8'); + content.split('\n').forEach(line => { + line = line.trim(); + if (line && !line.startsWith('#')) { + const negate = line.startsWith('!'); + if (negate) line = line.slice(1); + patterns.push({ pattern: line, negate }); + } + }); + } catch {} + + return patterns; +} + +function shouldIgnore(filePath, basePath, patterns) { + const relPath = path.relative(basePath, filePath); + const name = path.basename(filePath); + + let ignored = false; + for (const {pattern, negate} of patterns) { + let match = false; + + if (pattern.includes('*')) { + // 通配符匹配 + const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$'); + match = regex.test(name) || regex.test(relPath); + } else if (pattern.includes('/')) { + // 路径匹配 + match = relPath.includes(pattern) || relPath.startsWith(pattern); + } else { + // 文件名匹配 + match = name === pattern || relPath.includes(`/${pattern}`) || relPath.startsWith(pattern); + } + + if (match) ignored = !negate; + } + return ignored; +} + +function rglob(dir, filter, basePath = dir) { + const patterns = parseGitignore(basePath); const results = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); + + if (shouldIgnore(full, basePath, patterns)) continue; + if (entry.isDirectory()) { - results.push(...rglob(full, filter)); + results.push(...rglob(full, filter, basePath)); } else if (!filter || filter(entry.name, full)) { results.push(full); } diff --git a/test/gen-docs.test.js b/test/gen-docs.test.js new file mode 100644 index 0000000..cdbdacb --- /dev/null +++ b/test/gen-docs.test.js @@ -0,0 +1,83 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { spawn } = require('child_process'); + +// 集成测试:通过实际运行验证功能 +describe('gen-docs gitignore 支持', () => { + let tempDir; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gen-docs-test-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + function runGenDocs(targetPath, args = []) { + return new Promise((resolve, reject) => { + const scriptPath = path.join(__dirname, '../skills/tools/gen-docs/scripts/doc_generator.js'); + const child = spawn('node', [scriptPath, targetPath, '--json', ...args], { + stdio: 'pipe' + }); + + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (data) => stdout += data); + child.stderr.on('data', (data) => stderr += data); + + child.on('close', (code) => { + if (code === 0) { + try { + resolve(JSON.parse(stdout)); + } catch { + resolve({ stdout, stderr }); + } + } else { + reject(new Error(`Exit ${code}: ${stderr}`)); + } + }); + }); + } + + test('排除 node_modules 目录', async () => { + // 创建测试结构 + fs.mkdirSync(path.join(tempDir, 'node_modules')); + fs.mkdirSync(path.join(tempDir, 'src')); + fs.writeFileSync(path.join(tempDir, 'src/main.js'), 'console.log("test");'); + fs.writeFileSync(path.join(tempDir, 'node_modules/package.json'), '{}'); + + const result = await runGenDocs(tempDir, ['--force']); + + expect(result.status).toBe('success'); + + const readme = fs.readFileSync(path.join(tempDir, 'README.md'), 'utf8'); + expect(readme).toContain('src/main.js'); + expect(readme).not.toContain('node_modules'); + }); + + test('支持 .gitignore 规则排除代码目录', async () => { + // 创建 .gitignore + fs.writeFileSync(path.join(tempDir, '.gitignore'), 'dist/\n.cache/'); + + // 创建测试文件 - 用 .js 文件确保进入目录结构 + fs.mkdirSync(path.join(tempDir, 'src')); + fs.mkdirSync(path.join(tempDir, 'dist')); + fs.mkdirSync(path.join(tempDir, '.cache')); + fs.writeFileSync(path.join(tempDir, 'src/main.js'), 'export default {}'); + fs.writeFileSync(path.join(tempDir, 'dist/bundle.js'), 'built'); + fs.writeFileSync(path.join(tempDir, '.cache/cache.js'), 'cached'); + + const result = await runGenDocs(tempDir, ['--force']); + + expect(result.status).toBe('success'); + + const readme = fs.readFileSync(path.join(tempDir, 'README.md'), 'utf8'); + expect(readme).toContain('src/main.js'); // 正常文件应出现 + expect(readme).not.toContain('dist/bundle.js'); // 被 gitignore 排除 + expect(readme).not.toContain('.cache/cache.js');// 被 gitignore 排除 + }); +}); \ No newline at end of file diff --git a/test/install.test.js b/test/install.test.js index 910b89e..84ec52e 100644 --- a/test/install.test.js +++ b/test/install.test.js @@ -7,9 +7,13 @@ const os = require('os'); // install.js 核心函数测试 const { deepMergeNew, detectClaudeAuth, detectCodexAuth, - detectCclineBin, copyRecursive, shouldSkip, SETTINGS_TEMPLATE + detectCclineBin, copyRecursive, shouldSkip, SETTINGS_TEMPLATE, + scanInvocableSkills, generateCommandContent, installGeneratedCommands } = require('../bin/install'); +// utils.js 函数测试 +const { parseFrontmatter } = require('../bin/lib/utils'); + describe('deepMergeNew', () => { test('新键写入目标', () => { const target = {}; @@ -149,3 +153,429 @@ describe('SETTINGS_TEMPLATE', () => { expect(SETTINGS_TEMPLATE).toHaveProperty('outputStyle', 'abyss-cultivator'); }); }); + +// ══════════════════════════════════════════════════════ +// 斜杠命令核心函数测试 +// ══════════════════════════════════════════════════════ + +describe('parseFrontmatter', () => { + test('解析标准 frontmatter', () => { + const content = '---\nname: gen-docs\ndescription: 文档生成器\n---\n\n# Body'; + const meta = parseFrontmatter(content); + expect(meta).not.toBeNull(); + expect(meta.name).toBe('gen-docs'); + expect(meta.description).toBe('文档生成器'); + }); + + test('无 frontmatter 返回 null', () => { + expect(parseFrontmatter('# Just a heading\n\nNo frontmatter here.')).toBeNull(); + expect(parseFrontmatter('')).toBeNull(); + }); + + test('剥离引号包裹的值', () => { + const content = '---\nname: "quoted-name"\ndescription: \'single-quoted\'\n---'; + const meta = parseFrontmatter(content); + expect(meta.name).toBe('quoted-name'); + expect(meta.description).toBe('single-quoted'); + }); + + test('支持 key 中的连字符', () => { + const content = '---\nuser-invocable: true\nargument-hint: \nallowed-tools: Bash, Read\n---'; + const meta = parseFrontmatter(content); + expect(meta['user-invocable']).toBe('true'); + expect(meta['argument-hint']).toBe(''); + expect(meta['allowed-tools']).toBe('Bash, Read'); + }); + + test('忽略空行和无效行', () => { + const content = '---\nname: test\n\n # comment-like\nbad line without colon\ndescription: ok\n---'; + const meta = parseFrontmatter(content); + expect(meta.name).toBe('test'); + expect(meta.description).toBe('ok'); + expect(Object.keys(meta).length).toBe(2); + }); + + test('处理 Windows 换行符 (CRLF)', () => { + const content = '---\r\nname: win-test\r\ndescription: crlf\r\n---\r\n\r\nBody'; + const meta = parseFrontmatter(content); + expect(meta).not.toBeNull(); + expect(meta.name).toBe('win-test'); + }); +}); + +describe('scanInvocableSkills', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'abyss-scan-test-')); + }); + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function makeSkill(relPath, frontmatter, withScript) { + const dir = path.join(tmpDir, relPath); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'SKILL.md'), `---\n${frontmatter}\n---\n\n# Skill`); + if (withScript) { + const scriptsDir = path.join(dir, 'scripts'); + fs.mkdirSync(scriptsDir, { recursive: true }); + fs.writeFileSync(path.join(scriptsDir, 'run.js'), '// noop'); + } + } + + test('仅返回 user-invocable: true 的 skill', () => { + makeSkill('tools/gen-docs', 'name: gen-docs\nuser-invocable: true', true); + makeSkill('tools/verify-module', 'name: verify-module\nuser-invocable: true', true); + makeSkill('domains/security', 'name: security\nuser-invocable: false', false); + + const results = scanInvocableSkills(tmpDir); + expect(results.length).toBe(2); + const names = results.map(r => r.meta.name).sort(); + expect(names).toEqual(['gen-docs', 'verify-module']); + }); + + test('正确检测 hasScripts', () => { + makeSkill('tools/with-script', 'name: with-script\nuser-invocable: true', true); + makeSkill('domains/no-script', 'name: no-script\nuser-invocable: true', false); + + const results = scanInvocableSkills(tmpDir); + const withScript = results.find(r => r.meta.name === 'with-script'); + const noScript = results.find(r => r.meta.name === 'no-script'); + expect(withScript.hasScripts).toBe(true); + expect(noScript.hasScripts).toBe(false); + }); + + test('返回正确的 relPath', () => { + makeSkill('tools/gen-docs', 'name: gen-docs\nuser-invocable: true', true); + + const results = scanInvocableSkills(tmpDir); + expect(results[0].relPath).toBe(path.join('tools', 'gen-docs')); + }); + + test('空目录返回空数组', () => { + expect(scanInvocableSkills(tmpDir)).toEqual([]); + }); + + test('无 name 字段的 skill 被忽略', () => { + makeSkill('tools/no-name', 'user-invocable: true\ndescription: no name field', false); + + const results = scanInvocableSkills(tmpDir); + expect(results.length).toBe(0); + }); + + test('扫描真实 skills 目录', () => { + const realSkillsDir = path.join(__dirname, '..', 'skills'); + if (!fs.existsSync(realSkillsDir)) return; // CI 中可能不存在 + + const results = scanInvocableSkills(realSkillsDir); + // 至少有 gen-docs, verify-module, verify-change, verify-quality, verify-security, frontend-design + expect(results.length).toBeGreaterThanOrEqual(6); + + const names = results.map(r => r.meta.name); + expect(names).toContain('gen-docs'); + expect(names).toContain('verify-module'); + expect(names).toContain('frontend-design'); + }); +}); + +describe('generateCommandContent', () => { + test('有脚本的 skill: 包含一气呵成指令流', () => { + const meta = { + name: 'gen-docs', + description: '文档生成器', + 'argument-hint': '<模块路径> [--force]', + 'allowed-tools': 'Bash, Read, Write, Glob', + }; + const content = generateCommandContent(meta, 'tools/gen-docs', true); + + // Frontmatter 正确 + expect(content).toMatch(/^---\n/); + expect(content).toContain('name: gen-docs'); + expect(content).toContain('description: "文档生成器"'); + expect(content).toContain('argument-hint: "<模块路径> [--force]"'); + expect(content).toContain('allowed-tools: Bash, Read, Write, Glob'); + + // 一气呵成指令(关键:不能有「先…然后…」分步停顿) + expect(content).toContain('一气呵成'); + expect(content).toContain('不要在步骤间停顿'); + expect(content).toContain('不要停顿'); + + // SKILL.md 读取路径正确 + expect(content).toContain('~/.claude/skills/tools/gen-docs/SKILL.md'); + + // run_skill.js 执行命令正确 + expect(content).toContain('node ~/.claude/skills/run_skill.js gen-docs $ARGUMENTS'); + }); + + test('无脚本的 skill: 知识库模式', () => { + const meta = { + name: 'frontend-design', + description: '前端设计美学秘典', + 'allowed-tools': 'Read', + }; + const content = generateCommandContent(meta, 'domains/frontend-design', false); + + // Frontmatter 正确 + expect(content).toContain('name: frontend-design'); + expect(content).toContain('allowed-tools: Read'); + + // 知识库模式关键词 + expect(content).toContain('读取以下秘典'); + expect(content).toContain('~/.claude/skills/domains/frontend-design/SKILL.md'); + + // 不包含 run_skill.js 命令 + expect(content).not.toContain('run_skill.js'); + expect(content).not.toContain('一气呵成'); + }); + + test('无 argument-hint 时不输出该字段', () => { + const meta = { + name: 'test-skill', + description: 'test', + }; + const content = generateCommandContent(meta, 'test', false); + expect(content).not.toContain('argument-hint'); + }); + + test('无 allowed-tools 时默认 Read', () => { + const meta = { name: 'minimal', description: 'minimal skill' }; + const content = generateCommandContent(meta, 'minimal', false); + expect(content).toContain('allowed-tools: Read'); + }); + + test('空 skillRelPath 使用根路径', () => { + const meta = { name: 'root', description: 'root skill' }; + const content = generateCommandContent(meta, '', false); + expect(content).toContain('~/.claude/skills/SKILL.md'); + }); + + test('description 中的双引号被转义', () => { + const meta = { name: 'escaped', description: 'has "quotes" inside' }; + const content = generateCommandContent(meta, 'test', false); + expect(content).toContain('description: "has \\"quotes\\" inside"'); + }); +}); + +describe('installGeneratedCommands', () => { + let tmpDir, targetDir, backupDir, manifest; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'abyss-cmd-test-')); + targetDir = path.join(tmpDir, 'target'); + backupDir = path.join(tmpDir, 'backup'); + fs.mkdirSync(targetDir, { recursive: true }); + fs.mkdirSync(backupDir, { recursive: true }); + manifest = { installed: [], backups: [] }; + }); + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function makeSkillDir(base, relPath, frontmatter, withScript) { + const dir = path.join(base, relPath); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'SKILL.md'), `---\n${frontmatter}\n---\n\n# Skill`); + if (withScript) { + const scriptsDir = path.join(dir, 'scripts'); + fs.mkdirSync(scriptsDir, { recursive: true }); + fs.writeFileSync(path.join(scriptsDir, 'run.js'), '// noop'); + } + } + + test('为 user-invocable skill 生成 command 文件', () => { + const skillsSrc = path.join(tmpDir, 'skills'); + fs.mkdirSync(skillsSrc, { recursive: true }); + makeSkillDir(skillsSrc, 'tools/gen-docs', 'name: gen-docs\nuser-invocable: true', true); + makeSkillDir(skillsSrc, 'tools/verify-module', 'name: verify-module\nuser-invocable: true', true); + + const count = installGeneratedCommands(skillsSrc, targetDir, backupDir, manifest); + + expect(count).toBe(2); + expect(fs.existsSync(path.join(targetDir, 'commands', 'gen-docs.md'))).toBe(true); + expect(fs.existsSync(path.join(targetDir, 'commands', 'verify-module.md'))).toBe(true); + expect(manifest.installed).toContain('commands/gen-docs.md'); + expect(manifest.installed).toContain('commands/verify-module.md'); + }); + + test('已存在的 command 文件被备份', () => { + const skillsSrc = path.join(tmpDir, 'skills'); + fs.mkdirSync(skillsSrc, { recursive: true }); + makeSkillDir(skillsSrc, 'tools/gen-docs', 'name: gen-docs\nuser-invocable: true', true); + + // 预置一个同名 command 文件 + const cmdsDir = path.join(targetDir, 'commands'); + fs.mkdirSync(cmdsDir, { recursive: true }); + fs.writeFileSync(path.join(cmdsDir, 'gen-docs.md'), 'old content'); + + installGeneratedCommands(skillsSrc, targetDir, backupDir, manifest); + + // 原文件被备份 + expect(fs.existsSync(path.join(backupDir, 'commands', 'gen-docs.md'))).toBe(true); + expect(fs.readFileSync(path.join(backupDir, 'commands', 'gen-docs.md'), 'utf8')).toBe('old content'); + expect(manifest.backups).toContain('commands/gen-docs.md'); + + // 新文件已覆盖 + const newContent = fs.readFileSync(path.join(cmdsDir, 'gen-docs.md'), 'utf8'); + expect(newContent).toContain('name: gen-docs'); + expect(newContent).not.toBe('old content'); + }); + + test('无 user-invocable skill 时返回 0', () => { + const skillsSrc = path.join(tmpDir, 'skills'); + fs.mkdirSync(skillsSrc, { recursive: true }); + makeSkillDir(skillsSrc, 'domains/security', 'name: security\nuser-invocable: false', false); + + const count = installGeneratedCommands(skillsSrc, targetDir, backupDir, manifest); + expect(count).toBe(0); + expect(fs.existsSync(path.join(targetDir, 'commands'))).toBe(false); + }); + + test('生成的 command 文件内容格式正确', () => { + const skillsSrc = path.join(tmpDir, 'skills'); + fs.mkdirSync(skillsSrc, { recursive: true }); + makeSkillDir(skillsSrc, 'tools/gen-docs', + 'name: gen-docs\nuser-invocable: true\nargument-hint: \nallowed-tools: Bash, Read', + true); + + installGeneratedCommands(skillsSrc, targetDir, backupDir, manifest); + + const content = fs.readFileSync(path.join(targetDir, 'commands', 'gen-docs.md'), 'utf8'); + // 必须以 --- 开头(YAML frontmatter) + expect(content).toMatch(/^---\n/); + // 包含一气呵成指令流(有脚本的 skill) + expect(content).toContain('一气呵成'); + expect(content).toContain('run_skill.js gen-docs'); + }); +}); + +// ══════════════════════════════════════════════════════ +// 斜杠命令回归防护(防止 skills 目录重组后路径失效) +// ══════════════════════════════════════════════════════ + +describe('斜杠命令回归防护', () => { + const realSkillsDir = path.join(__dirname, '..', 'skills'); + const skillsExist = fs.existsSync(realSkillsDir); + + // 跳过条件:CI 中可能没有 skills 目录 + const describeIf = skillsExist ? describe : describe.skip; + + describeIf('SKILL.md 路径有效性烟雾测试', () => { + let invocableSkills; + + beforeAll(() => { + invocableSkills = scanInvocableSkills(realSkillsDir); + }); + + test('至少存在 6 个 user-invocable skill', () => { + expect(invocableSkills.length).toBeGreaterThanOrEqual(6); + }); + + test('所有 user-invocable skill 的 SKILL.md 路径必须真实存在', () => { + const missing = []; + + invocableSkills.forEach(({ meta, relPath }) => { + // 生成 command 内容 + const content = generateCommandContent(meta, relPath, false); + + // 从生成内容中提取 ~/.claude/skills/.../SKILL.md 路径 + const match = content.match(/~\/\.claude\/skills\/(.+?\/SKILL\.md)/); + expect(match).not.toBeNull(); + + // 将 ~/.claude/skills/X/SKILL.md 映射回真实 skills/X/SKILL.md + const extractedRelPath = match[1]; // e.g. "tools/gen-docs/SKILL.md" + const realPath = path.join(realSkillsDir, extractedRelPath); + + if (!fs.existsSync(realPath)) { + missing.push({ + name: meta.name, + expectedPath: realPath, + relPath: extractedRelPath, + }); + } + }); + + // 若 skills 目录重组但 relPath 算错,此处立刻爆红 + expect(missing).toEqual([]); + }); + }); + + describeIf('脚本引用完整性', () => { + let invocableSkills; + + beforeAll(() => { + invocableSkills = scanInvocableSkills(realSkillsDir); + }); + + test('有脚本的 skill 的 command 必须包含正确的 run_skill.js 调用', () => { + const errors = []; + + invocableSkills + .filter(s => s.hasScripts) + .forEach(({ meta, relPath, hasScripts }) => { + const content = generateCommandContent(meta, relPath, hasScripts); + + // 验证 command 内容包含 run_skill.js {name} $ARGUMENTS + const expectedCall = `run_skill.js ${meta.name} $ARGUMENTS`; + if (!content.includes(expectedCall)) { + errors.push({ + name: meta.name, + expected: expectedCall, + issue: 'run_skill.js 调用缺失或格式错误', + }); + } + + // 验证 skills/{relPath}/scripts/ 下确实存在 .js 文件 + const scriptsDir = path.join(realSkillsDir, relPath, 'scripts'); + const hasJsFiles = fs.existsSync(scriptsDir) && + fs.readdirSync(scriptsDir).some(f => f.endsWith('.js')); + if (!hasJsFiles) { + errors.push({ + name: meta.name, + scriptsDir, + issue: 'scripts/ 目录不存在或无 .js 文件', + }); + } + }); + + // 若脚本目录移走但 command 仍引用旧路径,此处立刻爆红 + expect(errors).toEqual([]); + }); + + test('无脚本的 skill 不应引用 run_skill.js', () => { + invocableSkills + .filter(s => !s.hasScripts) + .forEach(({ meta, relPath, hasScripts }) => { + const content = generateCommandContent(meta, relPath, hasScripts); + expect(content).not.toContain('run_skill.js'); + }); + }); + }); + + describeIf('command 文件名合法性', () => { + test('每个 skill 的 name 符合合法文件名格式', () => { + const invocableSkills = scanInvocableSkills(realSkillsDir); + const invalid = []; + + invocableSkills.forEach(({ meta }) => { + // 必须以小写字母开头,仅包含小写字母、数字、连字符 + if (!/^[a-z][a-z0-9-]*$/.test(meta.name)) { + invalid.push({ + name: meta.name, + issue: '名称不符合 /^[a-z][a-z0-9-]*$/ 格式', + }); + } + }); + + expect(invalid).toEqual([]); + }); + + test('skill name 无重复(防止 command 文件冲突)', () => { + const invocableSkills = scanInvocableSkills(realSkillsDir); + const names = invocableSkills.map(s => s.meta.name); + const duplicates = names.filter((n, i) => names.indexOf(n) !== i); + + expect(duplicates).toEqual([]); + }); + }); +});