fix(path-utils): deduplicate subdirectory agent names (#1422)

Fixes redundant skill names for agents in subdirectories where the
folder name matches the filename (e.g., tech-writer/tech-writer.md).

Changes:
- Deduplicate when last two path segments are identical
- Handle .yaml extension in addition to .md
- Add comprehensive test suite (40 tests)

Before: bmad-bmm-tech-writer-tech-writer.agent
After:  bmad-bmm-tech-writer.agent

Closes #1422

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
ZvoneO 2026-01-27 14:54:09 +01:00
parent 7d3d51ff4f
commit c035b2f247
2 changed files with 387 additions and 2 deletions

368
test/test-path-utils.js Normal file
View File

@ -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);
}

View File

@ -48,6 +48,7 @@ function toDashName(module, type, name) {
* Converts: 'bmm/agents/pm.md' 'bmad-bmm-pm.agent.md' * Converts: 'bmm/agents/pm.md' 'bmad-bmm-pm.agent.md'
* Converts: 'bmm/workflows/correct-course.md' 'bmad-bmm-correct-course.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: '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' * @param {string} relativePath - Path like 'bmm/agents/pm.md'
* @returns {string} Flat filename like 'bmad-bmm-pm.agent.md' or 'bmad-brainstorming.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'; 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 parts = withoutExt.split(/[/\\]/);
const module = parts[0]; const module = parts[0];
const type = parts[1]; 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); return toDashName(module, type, name);
} }