diff --git a/test/test-path-utils.js b/test/test-path-utils.js new file mode 100644 index 00000000..4104513a --- /dev/null +++ b/test/test-path-utils.js @@ -0,0 +1,368 @@ +/** + * Unit Tests for path-utils.js + * + * Tests the toDashPath function which converts hierarchical file paths + * to flat dash-separated naming for IDE skill registration. + * + * Fixes tested: + * - Deduplication of matching folder/file names (issue #1422) + * - Handling of .yaml extension in addition to .md + * + * Run: node test/test-path-utils.js + */ + +const { + toDashPath, + toDashName, + parseDashName, + isDashFormat, +} = require('../tools/cli/installers/lib/ide/shared/path-utils.js'); + +console.log('Running path-utils unit tests...\n'); + +let passed = 0; +let failed = 0; + +function test(name, actual, expected) { + if (actual === expected) { + console.log(`✓ ${name}`); + passed++; + } else { + console.log(`✗ ${name}`); + console.log(` Expected: ${expected}`); + console.log(` Actual: ${actual}`); + failed++; + } +} + +function testObject(name, actual, expected) { + const actualStr = JSON.stringify(actual); + const expectedStr = JSON.stringify(expected); + if (actualStr === expectedStr) { + console.log(`✓ ${name}`); + passed++; + } else { + console.log(`✗ ${name}`); + console.log(` Expected: ${expectedStr}`); + console.log(` Actual: ${actualStr}`); + failed++; + } +} + +// ============================================ +// CORE FUNCTIONALITY - Flat Files +// ============================================ + +console.log('\n--- Flat file paths (no subdirectory) ---'); + +test( + 'module agent: bmm/agents/dev.md', + toDashPath('bmm/agents/dev.md'), + 'bmad-bmm-dev.agent.md' +); + +test( + 'module workflow: bmm/workflows/sprint-planning.md', + toDashPath('bmm/workflows/sprint-planning.md'), + 'bmad-bmm-sprint-planning.md' +); + +test( + 'module task: bmm/tasks/create-doc.md', + toDashPath('bmm/tasks/create-doc.md'), + 'bmad-bmm-create-doc.md' +); + +test( + 'core agent (no module prefix): core/agents/bmad-master.md', + toDashPath('core/agents/bmad-master.md'), + 'bmad-bmad-master.agent.md' +); + +test( + 'core workflow: core/workflows/brainstorming.md', + toDashPath('core/workflows/brainstorming.md'), + 'bmad-brainstorming.md' +); + +test( + 'hyphenated names preserved: bmm/agents/quick-flow-solo-dev.md', + toDashPath('bmm/agents/quick-flow-solo-dev.md'), + 'bmad-bmm-quick-flow-solo-dev.agent.md' +); + +// ============================================ +// BUG FIX - Matching Folder/File Deduplication (Issue #1422) +// ============================================ + +console.log('\n--- Bug fix: Matching folder/file deduplication (issue #1422) ---'); + +test( + 'tech-writer/tech-writer.md → tech-writer (not tech-writer-tech-writer)', + toDashPath('bmm/agents/tech-writer/tech-writer.md'), + 'bmad-bmm-tech-writer.agent.md' +); + +test( + 'storyteller/storyteller.md → storyteller', + toDashPath('cis/agents/storyteller/storyteller.md'), + 'bmad-cis-storyteller.agent.md' +); + +test( + 'workflow with matching folder/file: party-mode/party-mode.md', + toDashPath('bmm/workflows/party-mode/party-mode.md'), + 'bmad-bmm-party-mode.md' +); + +test( + 'core agent with matching folder/file: master/master.md', + toDashPath('core/agents/master/master.md'), + 'bmad-master.agent.md' +); + +// ============================================ +// NON-MATCHING Nested Paths (Should NOT Dedupe) +// ============================================ + +console.log('\n--- Non-matching nested paths (no deduplication) ---'); + +test( + 'create-prd/workflow.md keeps both segments', + toDashPath('bmm/workflows/create-prd/workflow.md'), + 'bmad-bmm-create-prd-workflow.md' +); + +test( + 'document-project/checklist.md keeps both segments', + toDashPath('bmm/workflows/document-project/checklist.md'), + 'bmad-bmm-document-project-checklist.md' +); + +test( + 'tech-writer/sidecar.md keeps both segments', + toDashPath('bmm/agents/tech-writer/sidecar.md'), + 'bmad-bmm-tech-writer-sidecar.agent.md' +); + +test( + 'partial match NOT deduplicated: tech-writer/tech-writers.md', + toDashPath('bmm/agents/tech-writer/tech-writers.md'), + 'bmad-bmm-tech-writer-tech-writers.agent.md' +); + +test( + 'substring match NOT deduplicated: writer/tech-writer.md', + toDashPath('bmm/agents/writer/tech-writer.md'), + 'bmad-bmm-writer-tech-writer.agent.md' +); + +// ============================================ +// DEEP NESTING (3+ Levels) +// ============================================ + +console.log('\n--- Deep nesting (multiple subdirectories) ---'); + +test( + 'category/writer/writer.md deduplicates last two only', + toDashPath('bmm/agents/category/writer/writer.md'), + 'bmad-bmm-category-writer.agent.md' +); + +test( + 'ui/forms/validator/validator.md deduplicates last two only', + toDashPath('bmm/agents/ui/forms/validator/validator.md'), + 'bmad-bmm-ui-forms-validator.agent.md' +); + +test( + 'deep path with different last two: a/b/c/d.md', + toDashPath('bmm/agents/a/b/c/d.md'), + 'bmad-bmm-a-b-c-d.agent.md' +); + +test( + 'three matching segments: foo/foo/foo.md - only last two dedupe', + toDashPath('bmm/agents/foo/foo/foo.md'), + 'bmad-bmm-foo-foo.agent.md' +); + +// ============================================ +// CASE SENSITIVITY +// ============================================ + +console.log('\n--- Case sensitivity ---'); + +test( + 'different case NOT deduplicated: Tech-Writer/tech-writer.md', + toDashPath('bmm/agents/Tech-Writer/tech-writer.md'), + 'bmad-bmm-Tech-Writer-tech-writer.agent.md' +); + +test( + 'same case deduplicated: WRITER/WRITER.md', + toDashPath('bmm/agents/WRITER/WRITER.md'), + 'bmad-bmm-WRITER.agent.md' +); + +// ============================================ +// SPECIAL CHARACTERS IN NAMES +// ============================================ + +console.log('\n--- Special characters in names ---'); + +test( + 'names with numbers: v2-helper/v2-helper.md', + toDashPath('bmm/agents/v2-helper/v2-helper.md'), + 'bmad-bmm-v2-helper.agent.md' +); + +test( + 'names with underscores: my_agent/my_agent.md', + toDashPath('bmm/agents/my_agent/my_agent.md'), + 'bmad-bmm-my_agent.agent.md' +); + +// ============================================ +// INPUT VALIDATION & EDGE CASES +// ============================================ + +console.log('\n--- Input validation ---'); + +test('null returns fallback', toDashPath(null), 'bmad-unknown.md'); + +test('undefined returns fallback', toDashPath(undefined), 'bmad-unknown.md'); + +test('empty string returns fallback', toDashPath(''), 'bmad-unknown.md'); + +test('number returns fallback', toDashPath(123), 'bmad-unknown.md'); + +test('object returns fallback', toDashPath({}), 'bmad-unknown.md'); + +// ============================================ +// WINDOWS PATH SEPARATORS +// ============================================ + +console.log('\n--- Windows path separators (backslashes) ---'); + +test( + 'backslashes converted correctly', + toDashPath('bmm\\agents\\dev.md'), + 'bmad-bmm-dev.agent.md' +); + +test( + 'nested Windows path with matching folder/file', + toDashPath('bmm\\agents\\tech-writer\\tech-writer.md'), + 'bmad-bmm-tech-writer.agent.md' +); + +test( + 'mixed separators', + toDashPath('bmm/agents\\tech-writer/tech-writer.md'), + 'bmad-bmm-tech-writer.agent.md' +); + +// ============================================ +// FILE EXTENSION HANDLING +// ============================================ + +console.log('\n--- File extension handling ---'); + +test( + '.md extension removed', + toDashPath('bmm/agents/dev.md'), + 'bmad-bmm-dev.agent.md' +); + +test( + '.yaml extension removed (new fix)', + toDashPath('bmm/agents/dev.yaml'), + 'bmad-bmm-dev.agent.md' +); + +test( + '.yaml with matching folder/file', + toDashPath('bmm/agents/tech-writer/tech-writer.yaml'), + 'bmad-bmm-tech-writer.agent.md' +); + +test( + 'no extension handled', + toDashPath('bmm/agents/dev'), + 'bmad-bmm-dev.agent.md' +); + +// ============================================ +// ROUNDTRIP TESTS +// ============================================ + +// Note: parseDashName has pre-existing issues with .agent.md extension handling +// that are outside the scope of issue #1422. The tests below verify that +// toDashPath output is structurally valid for parseDashName, even if the +// parsed values have known issues. + +console.log('\n--- Roundtrip consistency (toDashPath → parseDashName) ---'); + +{ + const dashName = toDashPath('bmm/agents/dev.md'); + const parsed = parseDashName(dashName); + // Verify parseDashName returns an object (not null) for valid toDashPath output + if (parsed !== null && typeof parsed === 'object') { + console.log('✓ flat agent produces parseable output'); + passed++; + } else { + console.log('✗ flat agent produces parseable output'); + failed++; + } +} + +{ + const dashName = toDashPath('bmm/agents/tech-writer/tech-writer.md'); + const parsed = parseDashName(dashName); + if (parsed !== null && typeof parsed === 'object') { + console.log('✓ nested deduplicated agent produces parseable output'); + passed++; + } else { + console.log('✗ nested deduplicated agent produces parseable output'); + failed++; + } +} + +{ + const dashName = toDashPath('core/agents/master.md'); + const parsed = parseDashName(dashName); + if (parsed !== null && typeof parsed === 'object') { + console.log('✓ core agent produces parseable output'); + passed++; + } else { + console.log('✗ core agent produces parseable output'); + failed++; + } +} + +// ============================================ +// isDashFormat VALIDATION +// ============================================ + +console.log('\n--- isDashFormat validation ---'); + +test( + 'valid dash format returns true', + isDashFormat('bmad-bmm-dev.agent.md'), + true +); + +test('non-dash format returns false', isDashFormat('dev.md'), false); + +// ============================================ +// SUMMARY +// ============================================ + +console.log('\n========================================'); +console.log(`Results: ${passed} passed, ${failed} failed`); +console.log('========================================\n'); + +if (failed > 0) { + process.exit(1); +} diff --git a/tools/cli/installers/lib/ide/shared/path-utils.js b/tools/cli/installers/lib/ide/shared/path-utils.js index 7c335d4b..79c9baef 100644 --- a/tools/cli/installers/lib/ide/shared/path-utils.js +++ b/tools/cli/installers/lib/ide/shared/path-utils.js @@ -48,6 +48,7 @@ function toDashName(module, type, name) { * Converts: 'bmm/agents/pm.md' → 'bmad-bmm-pm.agent.md' * Converts: 'bmm/workflows/correct-course.md' → 'bmad-bmm-correct-course.md' * Converts: 'core/agents/brainstorming.md' → 'bmad-brainstorming.agent.md' (core items skip module prefix) + * Converts: 'bmm/agents/tech-writer/tech-writer.md' → 'bmad-bmm-tech-writer.agent.md' (deduplicates matching folder/file) * * @param {string} relativePath - Path like 'bmm/agents/pm.md' * @returns {string} Flat filename like 'bmad-bmm-pm.agent.md' or 'bmad-brainstorming.md' @@ -58,12 +59,28 @@ function toDashPath(relativePath) { return 'bmad-unknown.md'; } - const withoutExt = relativePath.replace('.md', ''); + // Handle both .md and .yaml extensions + const withoutExt = relativePath.replace(/\.(md|yaml)$/, ''); const parts = withoutExt.split(/[/\\]/); const module = parts[0]; const type = parts[1]; - const name = parts.slice(2).join('-'); + + // Get name parts (everything after module/type) + let nameParts = parts.slice(2); + + // Deduplicate when folder name matches filename + // e.g., ['tech-writer', 'tech-writer'] → ['tech-writer'] + // This prevents redundant skill names like 'bmad-bmm-tech-writer-tech-writer' + // Fixes: https://github.com/bmad-code-org/BMAD-METHOD/issues/1422 + if (nameParts.length >= 2) { + const lastTwo = nameParts.slice(-2); + if (lastTwo[0] === lastTwo[1]) { + nameParts = [...nameParts.slice(0, -2), lastTwo[0]]; + } + } + + const name = nameParts.join('-'); return toDashName(module, type, name); }