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:
138
cmd/bd/doctor.go
138
cmd/bd/doctor.go
@@ -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")
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user