feat: display external deps in bd dep tree (bd-vks2, bd-mv6h, bd-d9mu)

External dependencies (external:project:capability) are now visible in
the dependency tree output. Previously they were invisible because the
recursive CTE only JOINed against the issues table.

Changes:
- GetDependencyTree now fetches external deps and adds them as synthetic
  leaf nodes with resolution status (satisfied/blocked)
- formatTreeNode displays external deps with special formatting
- Added helper parseExternalRefParts for parsing external refs

Test coverage added for:
- External deps appearing in dependency tree
- Cycle detection ignoring external refs
- CheckExternalDep when target has no .beads directory
- Various invalid external ref format variations

Closes: bd-vks2, bd-mv6h, bd-d9mu
This commit is contained in:
Steve Yegge
2025-12-22 22:34:03 -08:00
parent 73b2e14919
commit ad02b80330
4 changed files with 373 additions and 0 deletions

View File

@@ -1652,3 +1652,115 @@ func TestGetBlockedIssuesPartialExternalDeps(t *testing.T) {
}
}
}
// TestCheckExternalDepNoBeadsDirectory verifies that CheckExternalDep
// correctly reports "no beads database" when the target project exists
// but has no .beads directory (bd-mv6h).
func TestCheckExternalDepNoBeadsDirectory(t *testing.T) {
ctx := context.Background()
// Create a project directory WITHOUT .beads
projectDir, err := os.MkdirTemp("", "beads-no-beads-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(projectDir)
// Initialize config if not already done
if err := config.Initialize(); err != nil {
t.Fatalf("failed to initialize config: %v", err)
}
// Configure external_projects to point to the directory
oldProjects := config.GetExternalProjects()
defer func() {
if oldProjects != nil {
config.Set("external_projects", oldProjects)
} else {
config.Set("external_projects", map[string]string{})
}
}()
config.Set("external_projects", map[string]string{
"no-beads-project": projectDir,
})
// Check the external dep - should report "no beads database"
status := CheckExternalDep(ctx, "external:no-beads-project:some-capability")
if status.Satisfied {
t.Error("Expected external dep to be unsatisfied when target has no .beads directory")
}
if status.Reason != "project has no beads database" {
t.Errorf("Expected reason 'project has no beads database', got: %s", status.Reason)
}
}
// TestCheckExternalDepInvalidFormats verifies that CheckExternalDep
// correctly handles various invalid external ref formats (bd-mv6h).
func TestCheckExternalDepInvalidFormats(t *testing.T) {
ctx := context.Background()
tests := []struct {
name string
ref string
wantReason string
}{
{
name: "not external prefix",
ref: "bd-xyz",
wantReason: "not an external reference",
},
{
name: "missing capability",
ref: "external:project",
wantReason: "invalid format (expected external:project:capability)",
},
{
name: "empty project",
ref: "external::capability",
wantReason: "missing project or capability",
},
{
name: "empty capability",
ref: "external:project:",
wantReason: "missing project or capability",
},
{
name: "only external prefix",
ref: "external:",
wantReason: "invalid format (expected external:project:capability)",
},
{
name: "unconfigured project",
ref: "external:unconfigured-project:capability",
wantReason: "project not configured in external_projects",
},
}
// Initialize config if not already done
if err := config.Initialize(); err != nil {
t.Fatalf("failed to initialize config: %v", err)
}
// Ensure no external projects are configured for some tests
oldProjects := config.GetExternalProjects()
defer func() {
if oldProjects != nil {
config.Set("external_projects", oldProjects)
}
}()
config.Set("external_projects", map[string]string{})
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
status := CheckExternalDep(ctx, tt.ref)
if status.Satisfied {
t.Errorf("Expected unsatisfied for %q", tt.ref)
}
if status.Reason != tt.wantReason {
t.Errorf("Expected reason %q, got %q", tt.wantReason, status.Reason)
}
})
}
}