feat: Add metadata.json version tracking validation to bd doctor (bd-u4sb)

Add comprehensive validation of metadata.json version tracking to bd doctor:

Checks added:
- metadata.json exists and is valid JSON
- LastBdVersion field is present and non-empty
- LastBdVersion is valid semver format (e.g., 0.24.2)
- Warns if LastBdVersion is very old (> 10 minor versions behind)
- Provides helpful fix messages for each validation failure

Implementation:
- New checkMetadataVersionTracking() function
- Helper functions: isValidSemver(), parseVersionParts()
- Comprehensive test coverage for all validation scenarios

Tests:
- TestCheckMetadataVersionTracking: 7 test cases covering all scenarios
- TestIsValidSemver: Version format validation
- TestParseVersionParts: Version parsing logic

This helps ensure version tracking (bd-loka) is working correctly and
alerts users if they've missed important upgrade notifications.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-11-23 17:28:25 -08:00
parent 238ce34b52
commit d8f3eb0c25
4 changed files with 308 additions and 1 deletions

View File

@@ -70,6 +70,7 @@ This command checks:
- Circular dependencies
- Git hooks (pre-commit, post-merge, pre-push)
- .beads/.gitignore up to date
- Metadata.json version tracking (LastBdVersion field)
Performance Mode (--perf):
Run performance diagnostics on your database:
@@ -342,6 +343,11 @@ func runDiagnostics(path string) doctorResult {
result.Checks = append(result.Checks, mergeDriverCheck)
// Don't fail overall check for merge driver, just warn
// Check 16: Metadata.json version tracking (bd-u4sb)
metadataCheck := checkMetadataVersionTracking(path)
result.Checks = append(result.Checks, metadataCheck)
// Don't fail overall check for metadata, just warn
return result
}
@@ -1586,6 +1592,138 @@ func checkMergeDriver(path string) doctorCheck {
}
}
func checkMetadataVersionTracking(path string) doctorCheck {
beadsDir := filepath.Join(path, ".beads")
// Load metadata.json
cfg, err := configfile.Load(beadsDir)
if err != nil {
return doctorCheck{
Name: "Metadata Version Tracking",
Status: statusError,
Message: "Unable to read metadata.json",
Detail: err.Error(),
Fix: "Ensure metadata.json exists and is valid JSON. Run 'bd init' if needed.",
}
}
// Check if metadata.json exists
if cfg == nil {
return doctorCheck{
Name: "Metadata Version Tracking",
Status: statusWarning,
Message: "metadata.json not found",
Fix: "Run any bd command to create metadata.json with version tracking",
}
}
// Check if LastBdVersion field is present
if cfg.LastBdVersion == "" {
return doctorCheck{
Name: "Metadata Version Tracking",
Status: statusWarning,
Message: "LastBdVersion field is empty (first run)",
Detail: "Version tracking will be initialized on next command",
Fix: "Run any bd command to initialize version tracking",
}
}
// Validate that LastBdVersion is a valid semver-like string
// Simple validation: should be X.Y.Z format where X, Y, Z are numbers
if !isValidSemver(cfg.LastBdVersion) {
return doctorCheck{
Name: "Metadata Version Tracking",
Status: statusWarning,
Message: fmt.Sprintf("LastBdVersion has invalid format: %q", cfg.LastBdVersion),
Detail: "Expected semver format like '0.24.2'",
Fix: "Run any bd command to reset version tracking to current version",
}
}
// Check if LastBdVersion is very old (> 10 versions behind)
// Calculate version distance
versionDiff := compareVersions(Version, cfg.LastBdVersion)
if versionDiff > 0 {
// Current version is newer - check how far behind
currentParts := parseVersionParts(Version)
lastParts := parseVersionParts(cfg.LastBdVersion)
// Simple heuristic: warn if minor version is 10+ behind or major version differs by 1+
majorDiff := currentParts[0] - lastParts[0]
minorDiff := currentParts[1] - lastParts[1]
if majorDiff >= 1 || (majorDiff == 0 && minorDiff >= 10) {
return doctorCheck{
Name: "Metadata Version Tracking",
Status: statusWarning,
Message: fmt.Sprintf("LastBdVersion is very old: %s (current: %s)", cfg.LastBdVersion, Version),
Detail: "You may have missed important upgrade notifications",
Fix: "Run 'bd upgrade review' to see recent changes",
}
}
// Version is behind but not too old
return doctorCheck{
Name: "Metadata Version Tracking",
Status: statusOK,
Message: fmt.Sprintf("Version tracking active (last: %s, current: %s)", cfg.LastBdVersion, Version),
}
}
// Version is current or ahead (shouldn't happen, but handle it)
return doctorCheck{
Name: "Metadata Version Tracking",
Status: statusOK,
Message: fmt.Sprintf("Version tracking active (version: %s)", cfg.LastBdVersion),
}
}
// isValidSemver checks if a version string is valid semver-like format (X.Y.Z)
func isValidSemver(version string) bool {
if version == "" {
return false
}
// Split by dots and ensure all parts are numeric
versionParts := strings.Split(version, ".")
if len(versionParts) < 1 {
return false
}
// Parse each part to ensure it's a valid number
for _, part := range versionParts {
if part == "" {
return false
}
var num int
if _, err := fmt.Sscanf(part, "%d", &num); err != nil {
return false
}
if num < 0 {
return false
}
}
return true
}
// parseVersionParts parses version string into numeric parts
// Returns [major, minor, patch, ...] or empty slice on error
func parseVersionParts(version string) []int {
parts := strings.Split(version, ".")
result := make([]int, 0, len(parts))
for _, part := range parts {
var num int
if _, err := fmt.Sscanf(part, "%d", &num); err != nil {
return result
}
result = append(result, num)
}
return result
}
func init() {
rootCmd.AddCommand(doctorCmd)
doctorCmd.Flags().BoolVar(&perfMode, "perf", false, "Run performance diagnostics and generate CPU profile")

View File

@@ -680,3 +680,171 @@ func TestCheckGitHooks(t *testing.T) {
})
}
}
func TestCheckMetadataVersionTracking(t *testing.T) {
tests := []struct {
name string
setupMetadata func(beadsDir string) error
expectedStatus string
expectWarning bool
}{
{
name: "valid current version",
setupMetadata: func(beadsDir string) error {
cfg := map[string]string{
"database": "beads.db",
"last_bd_version": Version,
}
data, _ := json.Marshal(cfg)
return os.WriteFile(filepath.Join(beadsDir, "metadata.json"), data, 0644)
},
expectedStatus: statusOK,
expectWarning: false,
},
{
name: "slightly outdated version",
setupMetadata: func(beadsDir string) error {
cfg := map[string]string{
"database": "beads.db",
"last_bd_version": "0.24.0",
}
data, _ := json.Marshal(cfg)
return os.WriteFile(filepath.Join(beadsDir, "metadata.json"), data, 0644)
},
expectedStatus: statusOK,
expectWarning: false,
},
{
name: "very old version",
setupMetadata: func(beadsDir string) error {
cfg := map[string]string{
"database": "beads.db",
"last_bd_version": "0.14.0",
}
data, _ := json.Marshal(cfg)
return os.WriteFile(filepath.Join(beadsDir, "metadata.json"), data, 0644)
},
expectedStatus: statusWarning,
expectWarning: true,
},
{
name: "empty version field",
setupMetadata: func(beadsDir string) error {
cfg := map[string]string{
"database": "beads.db",
"last_bd_version": "",
}
data, _ := json.Marshal(cfg)
return os.WriteFile(filepath.Join(beadsDir, "metadata.json"), data, 0644)
},
expectedStatus: statusWarning,
expectWarning: true,
},
{
name: "invalid version format",
setupMetadata: func(beadsDir string) error {
cfg := map[string]string{
"database": "beads.db",
"last_bd_version": "invalid-version",
}
data, _ := json.Marshal(cfg)
return os.WriteFile(filepath.Join(beadsDir, "metadata.json"), data, 0644)
},
expectedStatus: statusWarning,
expectWarning: true,
},
{
name: "corrupted metadata.json",
setupMetadata: func(beadsDir string) error {
return os.WriteFile(filepath.Join(beadsDir, "metadata.json"), []byte("{invalid json}"), 0644)
},
expectedStatus: statusError,
expectWarning: false,
},
{
name: "missing metadata.json",
setupMetadata: func(beadsDir string) error {
// Don't create metadata.json
return nil
},
expectedStatus: statusWarning,
expectWarning: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.Mkdir(beadsDir, 0750); err != nil {
t.Fatal(err)
}
// Setup metadata.json
if err := tc.setupMetadata(beadsDir); err != nil {
t.Fatal(err)
}
check := checkMetadataVersionTracking(tmpDir)
if check.Status != tc.expectedStatus {
t.Errorf("Expected status %s, got %s (message: %s)", tc.expectedStatus, check.Status, check.Message)
}
if tc.expectWarning && check.Status == statusWarning && check.Fix == "" {
t.Error("Expected fix message for warning status")
}
})
}
}
func TestIsValidSemver(t *testing.T) {
tests := []struct {
version string
expected bool
}{
{"0.24.2", true},
{"1.0.0", true},
{"0.1", true}, // Major.minor is valid
{"1", true}, // Just major is valid
{"", false}, // Empty is invalid
{"invalid", false}, // Non-numeric is invalid
{"0.a.2", false}, // Letters in parts are invalid
{"1.2.3.4", true}, // Extra parts are ok
}
for _, tc := range tests {
result := isValidSemver(tc.version)
if result != tc.expected {
t.Errorf("isValidSemver(%q) = %v, expected %v", tc.version, result, tc.expected)
}
}
}
func TestParseVersionParts(t *testing.T) {
tests := []struct {
version string
expected []int
}{
{"0.24.2", []int{0, 24, 2}},
{"1.0.0", []int{1, 0, 0}},
{"0.1", []int{0, 1}},
{"1", []int{1}},
{"", []int{}},
{"invalid", []int{}},
{"1.a.3", []int{1}}, // Stops at first non-numeric part
}
for _, tc := range tests {
result := parseVersionParts(tc.version)
if len(result) != len(tc.expected) {
t.Errorf("parseVersionParts(%q) returned %d parts, expected %d", tc.version, len(result), len(tc.expected))
continue
}
for i := range result {
if result[i] != tc.expected[i] {
t.Errorf("parseVersionParts(%q)[%d] = %d, expected %d", tc.version, i, result[i], tc.expected[i])
}
}
}
}