import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { DependencyResolver } from '../../../tools/cli/installers/lib/core/dependency-resolver.js'; import { createTempDir, cleanupTempDir, createTestFile } from '../../helpers/temp-dir.js'; import fs from 'fs-extra'; import path from 'node:path'; describe('DependencyResolver', () => { let tmpDir; let bmadDir; beforeEach(async () => { tmpDir = await createTempDir(); // Create structure: tmpDir/src/core and tmpDir/src/modules/ bmadDir = path.join(tmpDir, 'src'); await fs.ensureDir(path.join(bmadDir, 'core', 'agents')); await fs.ensureDir(path.join(bmadDir, 'core', 'tasks')); await fs.ensureDir(path.join(bmadDir, 'core', 'templates')); await fs.ensureDir(path.join(bmadDir, 'modules', 'bmm', 'agents')); await fs.ensureDir(path.join(bmadDir, 'modules', 'bmm', 'tasks')); }); afterEach(async () => { await cleanupTempDir(tmpDir); }); describe('basic resolution', () => { it('should resolve core agents with no dependencies', async () => { await createTestFile( bmadDir, 'core/agents/simple.md', `--- name: simple --- Simple agent`, ); const resolver = new DependencyResolver(); const result = await resolver.resolve(bmadDir, []); expect(result.primaryFiles).toHaveLength(1); expect(result.primaryFiles[0].type).toBe('agent'); expect(result.primaryFiles[0].module).toBe('core'); expect(result.allFiles).toHaveLength(1); }); it('should resolve multiple agents from same module', async () => { await createTestFile(bmadDir, 'core/agents/agent1.md', 'Agent 1'); await createTestFile(bmadDir, 'core/agents/agent2.md', 'Agent 2'); await createTestFile(bmadDir, 'core/agents/agent3.md', 'Agent 3'); const resolver = new DependencyResolver(); const result = await resolver.resolve(bmadDir, []); expect(result.primaryFiles).toHaveLength(3); expect(result.allFiles).toHaveLength(3); }); it('should always include core module', async () => { await createTestFile(bmadDir, 'core/agents/core-agent.md', 'Core'); const resolver = new DependencyResolver(); const result = await resolver.resolve(bmadDir, ['bmm']); // Core should be included even though only 'bmm' was requested expect(result.byModule.core).toBeDefined(); }); it('should skip agents with localskip="true"', async () => { await createTestFile(bmadDir, 'core/agents/normal.md', 'Normal agent'); await createTestFile(bmadDir, 'core/agents/webonly.md', 'Web only agent'); const resolver = new DependencyResolver(); const result = await resolver.resolve(bmadDir, []); expect(result.primaryFiles).toHaveLength(1); expect(result.primaryFiles[0].name).toBe('normal'); }); }); describe('path resolution variations', () => { it('should resolve {project-root}/bmad/core/tasks/foo.md dependencies', async () => { await createTestFile( bmadDir, 'core/agents/agent.md', `--- dependencies: ["{project-root}/bmad/core/tasks/task.md"] --- Agent with task dependency`, ); await createTestFile(bmadDir, 'core/tasks/task.md', 'Task content'); const resolver = new DependencyResolver(); const result = await resolver.resolve(bmadDir, []); expect(result.allFiles).toHaveLength(2); expect(result.dependencies.size).toBeGreaterThan(0); expect([...result.dependencies].some((d) => d.includes('task.md'))).toBe(true); }); it('should resolve relative path dependencies', async () => { await createTestFile( bmadDir, 'core/agents/agent.md', `--- template: "../templates/template.yaml" --- Agent with template`, ); await createTestFile(bmadDir, 'core/templates/template.yaml', 'template: data'); const resolver = new DependencyResolver(); const result = await resolver.resolve(bmadDir, []); expect(result.allFiles).toHaveLength(2); expect([...result.dependencies].some((d) => d.includes('template.yaml'))).toBe(true); }); it('should resolve glob pattern dependencies', async () => { await createTestFile( bmadDir, 'core/agents/agent.md', `--- dependencies: ["{project-root}/bmad/core/tasks/*.md"] --- Agent with multiple tasks`, ); await createTestFile(bmadDir, 'core/tasks/task1.md', 'Task 1'); await createTestFile(bmadDir, 'core/tasks/task2.md', 'Task 2'); await createTestFile(bmadDir, 'core/tasks/task3.md', 'Task 3'); const resolver = new DependencyResolver(); const result = await resolver.resolve(bmadDir, []); // Should find agent + 3 tasks expect(result.allFiles).toHaveLength(4); }); it('should resolve array of dependencies', async () => { await createTestFile( bmadDir, 'core/agents/agent.md', `--- dependencies: - "{project-root}/bmad/core/tasks/task1.md" - "{project-root}/bmad/core/tasks/task2.md" - "../templates/template.yaml" --- Agent`, ); await createTestFile(bmadDir, 'core/tasks/task1.md', 'Task 1'); await createTestFile(bmadDir, 'core/tasks/task2.md', 'Task 2'); await createTestFile(bmadDir, 'core/templates/template.yaml', 'template'); const resolver = new DependencyResolver(); const result = await resolver.resolve(bmadDir, []); expect(result.allFiles).toHaveLength(4); // agent + 2 tasks + template }); }); describe('command reference resolution', () => { it('should resolve @task-name references', async () => { await createTestFile( bmadDir, 'core/agents/agent.md', ` Use @task-analyze for analysis `, ); await createTestFile(bmadDir, 'core/tasks/analyze.md', 'Analyze task'); const resolver = new DependencyResolver(); const result = await resolver.resolve(bmadDir, []); expect(result.allFiles.length).toBeGreaterThanOrEqual(2); expect([...result.allFiles].some((f) => f.includes('analyze.md'))).toBe(true); }); it('should resolve @agent-name references', async () => { await createTestFile( bmadDir, 'core/agents/main.md', ` Reference @agent-helper for help `, ); await createTestFile(bmadDir, 'core/agents/helper.md', 'Helper'); const resolver = new DependencyResolver(); const result = await resolver.resolve(bmadDir, []); expect(result.allFiles).toHaveLength(2); expect([...result.allFiles].some((f) => f.includes('helper.md'))).toBe(true); }); it('should resolve bmad/module/type/name references', async () => { await createTestFile( bmadDir, 'core/agents/agent.md', ` See bmad/core/tasks/review `, ); await createTestFile(bmadDir, 'core/tasks/review.md', 'Review task'); const resolver = new DependencyResolver(); const result = await resolver.resolve(bmadDir, []); expect([...result.allFiles].some((f) => f.includes('review.md'))).toBe(true); }); }); describe('exec and tmpl attribute parsing', () => { it('should parse exec attributes from command tags', async () => { await createTestFile( bmadDir, 'core/agents/agent.md', ` `, ); await createTestFile(bmadDir, 'core/tasks/task.md', 'Task'); const resolver = new DependencyResolver(); const result = await resolver.resolve(bmadDir, []); expect([...result.allFiles].some((f) => f.includes('task.md'))).toBe(true); }); it('should parse tmpl attributes from command tags', async () => { await createTestFile( bmadDir, 'core/agents/agent.md', ` `, ); await createTestFile(bmadDir, 'core/templates/form.yaml', 'template'); const resolver = new DependencyResolver(); const result = await resolver.resolve(bmadDir, []); expect([...result.allFiles].some((f) => f.includes('form.yaml'))).toBe(true); }); it('should ignore exec="*" wildcard', async () => { await createTestFile( bmadDir, 'core/agents/agent.md', ` `, ); const resolver = new DependencyResolver(); const result = await resolver.resolve(bmadDir, []); // Should only have the agent itself expect(result.primaryFiles).toHaveLength(1); }); }); describe('multi-pass dependency resolution', () => { it('should resolve single-level dependencies (A→B)', async () => { await createTestFile( bmadDir, 'core/agents/agent-a.md', `--- dependencies: ["{project-root}/bmad/core/tasks/task-b.md"] --- Agent A`, ); await createTestFile(bmadDir, 'core/tasks/task-b.md', 'Task B'); const resolver = new DependencyResolver(); const result = await resolver.resolve(bmadDir, []); expect(result.allFiles).toHaveLength(2); // Primary files includes both agents and tasks from selected modules expect(result.primaryFiles.length).toBeGreaterThanOrEqual(1); expect(result.dependencies.size).toBeGreaterThanOrEqual(1); }); it('should resolve two-level dependencies (A→B→C)', async () => { await createTestFile( bmadDir, 'core/agents/agent-a.md', `--- dependencies: ["{project-root}/bmad/core/tasks/task-b.md"] --- Agent A`, ); await createTestFile( bmadDir, 'core/tasks/task-b.md', `--- template: "../templates/template-c.yaml" --- Task B content`, ); await createTestFile(bmadDir, 'core/templates/template-c.yaml', 'template: data'); const resolver = new DependencyResolver(); const result = await resolver.resolve(bmadDir, []); expect(result.allFiles).toHaveLength(3); // Primary files includes agents and tasks expect(result.primaryFiles.length).toBeGreaterThanOrEqual(1); // Total dependencies (direct + transitive) should be at least 2 const totalDeps = result.dependencies.size + result.transitiveDependencies.size; expect(totalDeps).toBeGreaterThanOrEqual(1); }); it('should resolve three-level dependencies (A→B→C→D)', async () => { await createTestFile( bmadDir, 'core/agents/agent-a.md', `--- dependencies: ["{project-root}/bmad/core/tasks/task-b.md"] --- A`, ); await createTestFile( bmadDir, 'core/tasks/task-b.md', `--- dependencies: ["{project-root}/bmad/core/tasks/task-c.md"] --- Task B`, ); await createTestFile( bmadDir, 'core/tasks/task-c.md', `--- template: "../templates/template-d.yaml" --- Task C`, ); await createTestFile(bmadDir, 'core/templates/template-d.yaml', 'Template D'); const resolver = new DependencyResolver(); const result = await resolver.resolve(bmadDir, []); expect(result.allFiles).toHaveLength(4); }); it('should resolve multiple branches (A→B, A→C)', async () => { await createTestFile( bmadDir, 'core/agents/agent-a.md', `--- dependencies: - "{project-root}/bmad/core/tasks/task-b.md" - "{project-root}/bmad/core/tasks/task-c.md" --- A`, ); await createTestFile(bmadDir, 'core/tasks/task-b.md', 'Task B'); await createTestFile(bmadDir, 'core/tasks/task-c.md', 'Task C'); const resolver = new DependencyResolver(); const result = await resolver.resolve(bmadDir, []); expect(result.allFiles).toHaveLength(3); expect(result.dependencies.size).toBe(2); }); it('should deduplicate diamond pattern (A→B,C; B,C→D)', async () => { await createTestFile( bmadDir, 'core/agents/agent-a.md', `--- dependencies: - "{project-root}/bmad/core/tasks/task-b.md" - "{project-root}/bmad/core/tasks/task-c.md" --- A`, ); await createTestFile( bmadDir, 'core/tasks/task-b.md', `--- template: "../templates/shared.yaml" --- Task B`, ); await createTestFile( bmadDir, 'core/tasks/task-c.md', `--- template: "../templates/shared.yaml" --- Task C`, ); await createTestFile(bmadDir, 'core/templates/shared.yaml', 'Shared template'); const resolver = new DependencyResolver(); const result = await resolver.resolve(bmadDir, []); // A + B + C + shared = 4 unique files (D appears twice but should be deduped) expect(result.allFiles).toHaveLength(4); }); }); describe('circular dependency detection', () => { it('should detect direct circular dependency (A→B→A)', async () => { await createTestFile( bmadDir, 'core/agents/agent-a.md', `--- dependencies: ["{project-root}/bmad/core/tasks/task-b.md"] --- A`, ); await createTestFile( bmadDir, 'core/tasks/task-b.md', `--- dependencies: ["{project-root}/bmad/core/agents/agent-a.md"] --- Task B`, ); const resolver = new DependencyResolver(); // Should not hang or crash const resultPromise = resolver.resolve(bmadDir, []); await expect(resultPromise).resolves.toBeDefined(); const result = await resultPromise; // Should process both files without infinite loop expect(result.allFiles.length).toBeGreaterThanOrEqual(2); }, 5000); // 5 second timeout to ensure no infinite loop it('should detect indirect circular dependency (A→B→C→A)', async () => { await createTestFile( bmadDir, 'core/agents/agent-a.md', `--- dependencies: ["{project-root}/bmad/core/tasks/task-b.md"] --- A`, ); await createTestFile( bmadDir, 'core/tasks/task-b.md', `--- dependencies: ["{project-root}/bmad/core/tasks/task-c.md"] --- Task B`, ); await createTestFile( bmadDir, 'core/tasks/task-c.md', `--- dependencies: ["{project-root}/bmad/core/agents/agent-a.md"] --- Task C`, ); const resolver = new DependencyResolver(); const resultPromise = resolver.resolve(bmadDir, []); await expect(resultPromise).resolves.toBeDefined(); const result = await resultPromise; // Should include all 3 files without duplicates expect(result.allFiles.length).toBeGreaterThanOrEqual(3); }, 5000); it('should handle self-reference (A→A)', async () => { await createTestFile( bmadDir, 'core/agents/agent-a.md', `--- dependencies: ["{project-root}/bmad/core/agents/agent-a.md"] --- A`, ); const resolver = new DependencyResolver(); const result = await resolver.resolve(bmadDir, []); // Should include the file once, not infinite times expect(result.allFiles).toHaveLength(1); }, 5000); }); describe('command reference parsing', () => { describe('parseCommandReferences()', () => { it('should extract @task- references', () => { const resolver = new DependencyResolver(); const content = 'Use @task-analyze for analysis\nThen @task-review'; const refs = resolver.parseCommandReferences(content); expect(refs).toContain('@task-analyze'); expect(refs).toContain('@task-review'); }); it('should extract @agent- references', () => { const resolver = new DependencyResolver(); const content = 'Call @agent-architect then @agent-developer'; const refs = resolver.parseCommandReferences(content); expect(refs).toContain('@agent-architect'); expect(refs).toContain('@agent-developer'); }); it('should extract bmad/ path references', () => { const resolver = new DependencyResolver(); const content = 'See bmad/core/agents/analyst and bmad/bmm/tasks/review'; const refs = resolver.parseCommandReferences(content); expect(refs).toContain('bmad/core/agents/analyst'); expect(refs).toContain('bmad/bmm/tasks/review'); }); it('should extract @bmad- references', () => { const resolver = new DependencyResolver(); const content = 'Use @bmad-master command'; const refs = resolver.parseCommandReferences(content); expect(refs).toContain('@bmad-master'); }); it('should handle multiple reference types in same content', () => { const resolver = new DependencyResolver(); const content = ` Use @task-analyze for analysis Then run @agent-architect Finally check bmad/core/tasks/review `; const refs = resolver.parseCommandReferences(content); expect(refs.length).toBeGreaterThanOrEqual(3); }); }); describe('parseFileReferences()', () => { it('should extract exec attribute paths', () => { const resolver = new DependencyResolver(); const content = ''; const refs = resolver.parseFileReferences(content); expect(refs).toContain('/bmad/core/tasks/foo.md'); }); it('should extract tmpl attribute paths', () => { const resolver = new DependencyResolver(); const content = ''; const refs = resolver.parseFileReferences(content); expect(refs).toContain('../templates/bar.yaml'); }); it('should extract relative file paths', () => { const resolver = new DependencyResolver(); const content = 'Load "./data/config.json" and "../templates/form.yaml"'; const refs = resolver.parseFileReferences(content); expect(refs).toContain('./data/config.json'); expect(refs).toContain('../templates/form.yaml'); }); it('should skip exec="*" wildcards', () => { const resolver = new DependencyResolver(); const content = ''; const refs = resolver.parseFileReferences(content); // Should not include "*" expect(refs).not.toContain('*'); }); }); }); describe('module organization', () => { it('should organize files by module correctly', async () => { await createTestFile(bmadDir, 'core/agents/core-agent.md', 'Core'); await createTestFile(bmadDir, 'modules/bmm/agents/bmm-agent.md', 'BMM'); const resolver = new DependencyResolver(); const result = await resolver.resolve(bmadDir, ['bmm']); expect(result.byModule.core).toBeDefined(); expect(result.byModule.bmm).toBeDefined(); expect(result.byModule.core.agents).toHaveLength(1); expect(result.byModule.bmm.agents).toHaveLength(1); }); it('should categorize files by type', async () => { await createTestFile(bmadDir, 'core/agents/agent.md', 'Agent'); await createTestFile(bmadDir, 'core/tasks/task.md', 'Task'); await createTestFile(bmadDir, 'core/templates/template.yaml', 'template'); const resolver = new DependencyResolver(); const files = [ path.join(bmadDir, 'core/agents/agent.md'), path.join(bmadDir, 'core/tasks/task.md'), path.join(bmadDir, 'core/templates/template.yaml'), ]; const organized = resolver.organizeByModule(bmadDir, new Set(files)); expect(organized.core.agents).toHaveLength(1); expect(organized.core.tasks).toHaveLength(1); expect(organized.core.templates).toHaveLength(1); }); it('should treat brain-tech as data, not tasks', async () => { await createTestFile(bmadDir, 'core/tasks/brain-tech/data.csv', 'col1,col2\nval1,val2'); const resolver = new DependencyResolver(); const files = [path.join(bmadDir, 'core/tasks/brain-tech/data.csv')]; const organized = resolver.organizeByModule(bmadDir, new Set(files)); expect(organized.core.data).toHaveLength(1); expect(organized.core.tasks).toHaveLength(0); }); }); describe('getModuleFromPath()', () => { it('should extract module from src/core path', () => { const resolver = new DependencyResolver(); const filePath = path.join(bmadDir, 'core/agents/agent.md'); const module = resolver.getModuleFromPath(bmadDir, filePath); expect(module).toBe('core'); }); it('should extract module from src/modules/bmm path', () => { const resolver = new DependencyResolver(); const filePath = path.join(bmadDir, 'modules/bmm/agents/pm.md'); const module = resolver.getModuleFromPath(bmadDir, filePath); expect(module).toBe('bmm'); }); it('should handle installed directory structure', async () => { // Create installed structure (no src/ prefix) const installedDir = path.join(tmpDir, 'installed'); await fs.ensureDir(path.join(installedDir, 'core/agents')); await fs.ensureDir(path.join(installedDir, 'modules/bmm/agents')); const resolver = new DependencyResolver(); const coreFile = path.join(installedDir, 'core/agents/agent.md'); const moduleFile = path.join(installedDir, 'modules/bmm/agents/pm.md'); expect(resolver.getModuleFromPath(installedDir, coreFile)).toBe('core'); expect(resolver.getModuleFromPath(installedDir, moduleFile)).toBe('bmm'); }); }); describe('edge cases', () => { it('should handle malformed YAML frontmatter', async () => { await createTestFile( bmadDir, 'core/agents/bad-yaml.md', `--- dependencies: [invalid: yaml: here --- Agent`, ); const resolver = new DependencyResolver(); // Should not crash, just warn and continue await expect(resolver.resolve(bmadDir, [])).resolves.toBeDefined(); }); it('should handle backticks in YAML values', async () => { await createTestFile( bmadDir, 'core/agents/backticks.md', `--- name: \`test\` dependencies: [\`{project-root}/bmad/core/tasks/task.md\`] --- Agent`, ); await createTestFile(bmadDir, 'core/tasks/task.md', 'Task'); const resolver = new DependencyResolver(); const result = await resolver.resolve(bmadDir, []); // Backticks should be pre-processed expect(result.allFiles.length).toBeGreaterThanOrEqual(1); }); it('should handle missing dependencies gracefully', async () => { await createTestFile( bmadDir, 'core/agents/agent.md', `--- dependencies: ["{project-root}/bmad/core/tasks/missing.md"] --- Agent`, ); // Don't create missing.md const resolver = new DependencyResolver(); const result = await resolver.resolve(bmadDir, []); expect(result.primaryFiles.length).toBeGreaterThanOrEqual(1); // Implementation may or may not track missing dependencies // Just verify it doesn't crash expect(result).toBeDefined(); }); it('should handle empty dependencies array', async () => { await createTestFile( bmadDir, 'core/agents/agent.md', `--- dependencies: [] --- Agent`, ); const resolver = new DependencyResolver(); const result = await resolver.resolve(bmadDir, []); expect(result.primaryFiles).toHaveLength(1); expect(result.allFiles).toHaveLength(1); }); it('should handle missing frontmatter', async () => { await createTestFile(bmadDir, 'core/agents/no-frontmatter.md', 'Agent'); const resolver = new DependencyResolver(); const result = await resolver.resolve(bmadDir, []); expect(result.primaryFiles).toHaveLength(1); expect(result.allFiles).toHaveLength(1); }); it('should handle non-existent module directory', async () => { // Create at least one core file so core module appears await createTestFile(bmadDir, 'core/agents/core-agent.md', 'Core'); const resolver = new DependencyResolver(); const result = await resolver.resolve(bmadDir, ['nonexistent']); // Should include core even though nonexistent module not found expect(result.byModule.core).toBeDefined(); expect(result.byModule.nonexistent).toBeUndefined(); }); }); describe('cross-module dependencies', () => { it('should resolve dependencies across modules', async () => { await createTestFile(bmadDir, 'core/agents/core-agent.md', 'Core'); await createTestFile( bmadDir, 'modules/bmm/agents/bmm-agent.md', `--- dependencies: ["{project-root}/bmad/core/tasks/shared-task.md"] --- BMM Agent`, ); await createTestFile(bmadDir, 'core/tasks/shared-task.md', 'Shared task'); const resolver = new DependencyResolver(); const result = await resolver.resolve(bmadDir, ['bmm']); // Should include: core agent + bmm agent + shared task expect(result.allFiles.length).toBeGreaterThanOrEqual(3); expect(result.byModule.core).toBeDefined(); expect(result.byModule.bmm).toBeDefined(); }); it('should resolve module tasks', async () => { await createTestFile(bmadDir, 'core/agents/core-agent.md', 'Core'); await createTestFile(bmadDir, 'modules/bmm/agents/pm.md', 'PM'); await createTestFile(bmadDir, 'modules/bmm/tasks/create-prd.md', 'Create PRD task'); const resolver = new DependencyResolver(); const result = await resolver.resolve(bmadDir, ['bmm']); expect(result.byModule.bmm.agents).toHaveLength(1); expect(result.byModule.bmm.tasks).toHaveLength(1); }); }); });