fix: address code review — fix interactive flow crash, simplify resolveCustomContentPaths

- Fix promptCustomContentSource() to use resolveCustomContentPaths()
  instead of reading module.yaml from root (would crash on parent dirs
  since validation was loosened but consumer was not updated)
- Simplify resolveCustomContentPaths() to only check file existence,
  not parse YAML (eliminates double read — callers already handle
  parsing and validation)
- Add edge case tests: module.yaml without code field, malformed YAML
  in subdirs, mixed direct + parent path resolution

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Henry Ávila 2026-03-17 14:48:59 -03:00
parent 7530f3b58a
commit 177353c77a
2 changed files with 101 additions and 29 deletions

View File

@ -67,6 +67,10 @@ async function createParentDirFixture() {
await fs.ensureDir(path.join(parentDir, 'not-a-module'));
await fs.writeFile(path.join(parentDir, 'not-a-module', 'readme.md'), '# Not a module\n');
// Module with missing code field (invalid)
await fs.ensureDir(path.join(parentDir, 'bad-module'));
await fs.writeFile(path.join(parentDir, 'bad-module', 'module.yaml'), 'name: Bad Module\n');
return parentDir;
}
@ -154,22 +158,26 @@ async function runTests() {
`Expected ${directModuleDir}, got ${directPaths[0]}`,
);
// Parent dir expands to individual module subdirs
// Parent dir expands to individual module subdirs (any dir with module.yaml)
const parentPaths = ui.resolveCustomContentPaths(parentDir);
assert(Array.isArray(parentPaths), 'resolveCustomContentPaths returns an array for parent dir');
assert(
parentPaths.length === 2,
'Parent dir resolves to 2 module paths (skips not-a-module)',
`Expected 2, got ${parentPaths.length}: ${JSON.stringify(parentPaths)}`,
parentPaths.length === 3,
'Parent dir resolves to 3 module paths (skips not-a-module which has no module.yaml)',
`Expected 3, got ${parentPaths.length}: ${JSON.stringify(parentPaths)}`,
);
const resolvedNames = parentPaths.map((p) => path.basename(p)).sort();
assert(
resolvedNames[0] === 'module-a' && resolvedNames[1] === 'module-b',
'Parent dir resolves to module-a and module-b',
resolvedNames.includes('module-a') && resolvedNames.includes('module-b'),
'Parent dir includes module-a and module-b',
`Got: ${JSON.stringify(resolvedNames)}`,
);
// bad-module has module.yaml but no code field — resolveCustomContentPaths includes it
// (callers are responsible for filtering invalid modules)
assert(resolvedNames.includes('bad-module'), 'Parent dir includes bad-module (has module.yaml, callers filter by code)');
// Empty dir returns empty array
const emptyPaths = ui.resolveCustomContentPaths(emptyDir);
assert(Array.isArray(emptyPaths), 'resolveCustomContentPaths returns an array for empty dir');
@ -185,6 +193,55 @@ async function runTests() {
if (emptyDir) await fs.remove(emptyDir).catch(() => {});
}
// ============================================================
// Test Suite 3: Edge cases
// ============================================================
console.log(`\n${colors.yellow}Test Suite 3: Edge cases${colors.reset}\n`);
let parentWithBadModule;
let directModule2;
try {
// Parent dir where only subdir has module.yaml without code field
parentWithBadModule = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-bad-'));
await fs.ensureDir(path.join(parentWithBadModule, 'no-code-module'));
await fs.writeFile(path.join(parentWithBadModule, 'no-code-module', 'module.yaml'), 'name: No Code Module\n');
// resolveCustomContentPaths includes it (callers filter)
const badPaths = ui.resolveCustomContentPaths(parentWithBadModule);
assert(badPaths.length === 1, 'Dir with only code-less module.yaml still resolves (callers filter)');
// validateCustomContentPathSync accepts it (has module.yaml in subdir)
const badValidation = ui.validateCustomContentPathSync(parentWithBadModule);
assert(
badValidation === undefined,
'Parent dir with code-less module.yaml passes validation (callers handle filtering)',
`Got error: ${badValidation}`,
);
// Subdir with malformed YAML
await fs.ensureDir(path.join(parentWithBadModule, 'malformed'));
await fs.writeFile(path.join(parentWithBadModule, 'malformed', 'module.yaml'), '{{invalid yaml');
const malformedPaths = ui.resolveCustomContentPaths(parentWithBadModule);
assert(malformedPaths.length === 2, 'Subdirs with malformed YAML are still resolved (callers handle parse errors)');
// Direct module alongside parent dir (simulates comma-separated usage)
directModule2 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-direct2-'));
await fs.writeFile(path.join(directModule2, 'module.yaml'), 'code: direct-2\nname: Direct Two\n');
const directPaths2 = ui.resolveCustomContentPaths(directModule2);
const parentPaths2 = ui.resolveCustomContentPaths(parentWithBadModule);
const combined = [...directPaths2, ...parentPaths2];
assert(
combined.length === 3,
'Mixed direct + parent paths combine correctly (1 direct + 2 from parent)',
`Expected 3, got ${combined.length}`,
);
} finally {
if (parentWithBadModule) await fs.remove(parentWithBadModule).catch(() => {});
if (directModule2) await fs.remove(directModule2).catch(() => {});
}
// ============================================================
// Summary
// ============================================================

View File

@ -1500,17 +1500,7 @@ class UI {
if (entry.isDirectory()) {
const subModuleYaml = path.join(inputPath, entry.name, 'module.yaml');
if (fs.pathExistsSync(subModuleYaml)) {
// Validate the module.yaml has a code field
try {
const yaml = require('yaml');
const content = fs.readFileSync(subModuleYaml, 'utf8');
const moduleData = yaml.parse(content);
if (moduleData && moduleData.code) {
resolved.push(path.join(inputPath, entry.name));
}
} catch {
// Skip invalid module.yaml files
}
resolved.push(path.join(inputPath, entry.name));
}
}
}
@ -1624,20 +1614,45 @@ class UI {
isValid = true;
}
// Read module.yaml to get module info
const yaml = require('yaml');
const moduleYamlPath = path.join(sourcePath, 'module.yaml');
const moduleContent = await fs.readFile(moduleYamlPath, 'utf8');
const moduleData = yaml.parse(moduleContent);
// Resolve path to module directories (supports parent dirs with subdirectories)
const resolvedPaths = this.resolveCustomContentPaths(sourcePath);
// Add to sources
customContentConfig.sources.push({
path: sourcePath,
id: moduleData.code,
name: moduleData.name || moduleData.code,
});
if (resolvedPaths.length === 0) {
await prompts.log.warn(`No module.yaml found in ${sourcePath} (checked root and immediate subdirectories)`);
continue;
}
await prompts.log.success(`Confirmed local custom module: ${moduleData.name || moduleData.code}`);
if (resolvedPaths.length > 1) {
await prompts.log.info(`Found ${resolvedPaths.length} modules in ${sourcePath}`);
}
for (const modulePath of resolvedPaths) {
// Read module.yaml to get module info
const yaml = require('yaml');
const moduleYamlPath = path.join(modulePath, 'module.yaml');
let moduleData;
try {
const moduleContent = await fs.readFile(moduleYamlPath, 'utf8');
moduleData = yaml.parse(moduleContent);
} catch (error) {
await prompts.log.warn(`Skipping ${modulePath} - failed to read module.yaml: ${error.message}`);
continue;
}
if (!moduleData || !moduleData.code) {
await prompts.log.warn(`Skipping ${modulePath} - module.yaml missing 'code' field`);
continue;
}
// Add to sources
customContentConfig.sources.push({
path: modulePath,
id: moduleData.code,
name: moduleData.name || moduleData.code,
});
await prompts.log.success(`Confirmed local custom module: ${moduleData.name || moduleData.code}`);
}
}
// Ask if user wants to add these to the installation