fix(validation): support hyphenated prefixes in ValidateIDFormat (#1013)

* test(validation): add failing tests for hyphenated prefix parsing

Reproduces bug where `bd create --parent` fails for projects with
hyphenated prefixes like "bead-me-up" or "web-app".

Root cause: ValidateIDFormat splits on first hyphen, so:
  "bead-me-up-3e9.1" → prefix "bead" (wrong, should be "bead-me-up")

The bug flow in create.go:
1. User runs: bd create "Child" --parent bead-me-up-3e9
2. GetNextChildID generates: bead-me-up-3e9.1
3. ValidateIDFormat extracts: "bead" (splits at first hyphen)
4. ValidatePrefix compares: "bead" vs "bead-me-up" → MISMATCH

Tests added:
- TestValidateIDFormat: 6 cases for hyphenated prefix IDs
- TestValidateIDFormat_ParentChildFlow: simulates exact --parent flow,
  showing simple prefixes pass while hyphenated prefixes fail

Workaround: use --force flag to bypass prefix validation.

* fix(validation): support hyphenated prefixes in ValidateIDFormat

Use utils.ExtractIssuePrefix instead of naive first-hyphen splitting.
This fixes bd create --parent failing for projects with hyphenated
prefixes like "bead-me-up" or "web-app".

Before: "bead-me-up-3e9" → prefix "bead" (wrong)
After:  "bead-me-up-3e9" → prefix "bead-me-up" (correct)

ExtractIssuePrefix uses smart heuristics: split on last hyphen,
check if suffix is hash-like (3-8 chars, alphanumeric, digits for 4+).

* Update internal/validation/bead_test.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Eugene Sukhodolin
2026-01-11 18:16:48 -08:00
committed by GitHub
parent 5941544a5e
commit d04bffb9b6
2 changed files with 101 additions and 3 deletions

View File

@@ -5,6 +5,7 @@ import (
"strings"
"github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/utils"
)
// ParsePriority extracts and validates a priority value from content.
@@ -51,6 +52,7 @@ func ValidatePriority(priorityStr string) (int, error) {
// ValidateIDFormat validates that an ID has the correct format.
// Supports: prefix-number (bd-42), prefix-hash (bd-a3f8e9), or hierarchical (bd-a3f8e9.1)
// Also supports hyphenated prefixes like "bead-me-up-3e9" or "web-app-abc123".
// Returns the prefix part or an error if invalid.
func ValidateIDFormat(id string) (string, error) {
if id == "" {
@@ -62,9 +64,11 @@ func ValidateIDFormat(id string) (string, error) {
return "", fmt.Errorf("invalid ID format '%s' (expected format: prefix-hash or prefix-hash.number, e.g., 'bd-a3f8e9' or 'bd-a3f8e9.1')", id)
}
// Extract prefix (before the first hyphen)
hyphenIdx := strings.Index(id, "-")
prefix := id[:hyphenIdx]
// Use ExtractIssuePrefix which correctly handles hyphenated prefixes
// by looking at the last hyphen and checking if suffix is hash-like.
// This fixes the bug where "bead-me-up-3e9" was parsed as prefix "bead"
// instead of "bead-me-up".
prefix := utils.ExtractIssuePrefix(id)
return prefix, nil
}

View File

@@ -93,6 +93,16 @@ func TestValidateIDFormat(t *testing.T) {
{"bd-a3f8e9.1", "bd", false},
{"foo-bar", "foo", false},
{"nohyphen", "", true},
// Hyphenated prefix support
// These test cases verify that ValidateIDFormat correctly extracts
// prefixes containing hyphens (e.g., "bead-me-up" not just "bead")
{"bead-me-up-3e9", "bead-me-up", false}, // 3-char hash suffix
{"bead-me-up-3e9.1", "bead-me-up", false}, // hierarchical child
{"bead-me-up-3e9.1.2", "bead-me-up", false}, // deeply nested child
{"web-app-a3f8e9", "web-app", false}, // 6-char hash suffix
{"my-cool-project-1a2b", "my-cool-project", false}, // 4-char hash suffix
{"document-intelligence-0sa", "document-intelligence", false}, // 3-char hash
}
for _, tt := range tests {
@@ -109,6 +119,90 @@ func TestValidateIDFormat(t *testing.T) {
}
}
// TestValidateIDFormat_ParentChildFlow tests the exact scenario that fails with --parent flag:
// When creating a child of a parent with a hyphenated prefix, the generated child ID
// (e.g., "bead-me-up-3e9.1") should have its prefix correctly extracted as "bead-me-up",
// not "bead". This test simulates the create.go flow at lines 352-391.
func TestValidateIDFormat_ParentChildFlow(t *testing.T) {
tests := []struct {
name string
parentID string
childSuffix string
dbPrefix string
wantPrefix string
shouldMatch bool
}{
{
name: "simple prefix - child creation works",
parentID: "bd-a3f8e9",
childSuffix: ".1",
dbPrefix: "bd",
wantPrefix: "bd",
shouldMatch: true,
},
{
name: "hyphenated prefix - child creation FAILS with current impl",
parentID: "bead-me-up-3e9",
childSuffix: ".1",
dbPrefix: "bead-me-up",
wantPrefix: "bead-me-up", // Current impl returns "bead" - THIS IS THE BUG
shouldMatch: true,
},
{
name: "hyphenated prefix - deeply nested child",
parentID: "bead-me-up-3e9.1",
childSuffix: ".2",
dbPrefix: "bead-me-up",
wantPrefix: "bead-me-up",
shouldMatch: true,
},
{
name: "multi-hyphen prefix - web-app style",
parentID: "web-app-abc123",
childSuffix: ".1",
dbPrefix: "web-app",
wantPrefix: "web-app",
shouldMatch: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Simulate the child ID generation that happens in create.go:352
childID := tt.parentID + tt.childSuffix
// Simulate the validation flow from create.go:361
extractedPrefix, err := ValidateIDFormat(childID)
if err != nil {
t.Fatalf("ValidateIDFormat(%q) unexpected error: %v", childID, err)
}
// Check that extracted prefix matches expected
if extractedPrefix != tt.wantPrefix {
t.Errorf("ValidateIDFormat(%q) extracted prefix = %q, want %q",
childID, extractedPrefix, tt.wantPrefix)
}
// Simulate the prefix validation from create.go:389
// This is where the "prefix mismatch" error occurs
err = ValidatePrefix(extractedPrefix, tt.dbPrefix, false)
prefixMatches := (err == nil)
if prefixMatches != tt.shouldMatch {
if tt.shouldMatch {
t.Errorf("--parent %s flow: prefix validation failed unexpectedly: %v\n"+
" Child ID: %s\n"+
" Extracted prefix: %q\n"+
" Database prefix: %q",
tt.parentID, err, childID, extractedPrefix, tt.dbPrefix)
} else {
t.Errorf("--parent %s flow: expected prefix mismatch but validation passed", tt.parentID)
}
}
})
}
}
func TestParseIssueType(t *testing.T) {
tests := []struct {
name string