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:
parent
7d3d51ff4f
commit
c035b2f247
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue