diff --git a/internal/cmd/doctor.go b/internal/cmd/doctor.go index 4893af44..ae3ec6d9 100644 --- a/internal/cmd/doctor.go +++ b/internal/cmd/doctor.go @@ -60,6 +60,7 @@ Rig checks (with --rig flag): Routing checks (fixable): - routes-config Check beads routing configuration + - prefix-mismatch Detect rigs.json vs routes.jsonl prefix mismatches (fixable) Session hook checks: - session-hooks Check settings.json use session-start.sh @@ -111,6 +112,7 @@ func runDoctor(cmd *cobra.Command, args []string) error { d.Register(doctor.NewBeadsDatabaseCheck()) d.Register(doctor.NewBdDaemonCheck()) d.Register(doctor.NewPrefixConflictCheck()) + d.Register(doctor.NewPrefixMismatchCheck()) d.Register(doctor.NewRoutesCheck()) d.Register(doctor.NewOrphanSessionCheck()) d.Register(doctor.NewOrphanProcessCheck()) diff --git a/internal/doctor/beads_check.go b/internal/doctor/beads_check.go index fc09003b..85aac9fa 100644 --- a/internal/doctor/beads_check.go +++ b/internal/doctor/beads_check.go @@ -2,6 +2,7 @@ package doctor import ( "bytes" + "encoding/json" "fmt" "os" "os/exec" @@ -225,3 +226,210 @@ func (c *PrefixConflictCheck) Run(ctx *CheckContext) *CheckResult { FixHint: "Use 'bd rename-prefix ' in one of the conflicting rigs to resolve", } } + +// PrefixMismatchCheck detects when rigs.json has a different prefix than what +// routes.jsonl actually uses for a rig. This can happen when: +// - deriveBeadsPrefix() generates a different prefix than what's in the beads DB +// - Someone manually edited rigs.json with the wrong prefix +// - The beads were initialized before auto-derive existed with a different prefix +type PrefixMismatchCheck struct { + FixableCheck +} + +// NewPrefixMismatchCheck creates a new prefix mismatch check. +func NewPrefixMismatchCheck() *PrefixMismatchCheck { + return &PrefixMismatchCheck{ + FixableCheck: FixableCheck{ + BaseCheck: BaseCheck{ + CheckName: "prefix-mismatch", + CheckDescription: "Check for prefix mismatches between rigs.json and routes.jsonl", + }, + }, + } +} + +// Run checks for prefix mismatches between rigs.json and routes.jsonl. +func (c *PrefixMismatchCheck) Run(ctx *CheckContext) *CheckResult { + beadsDir := filepath.Join(ctx.TownRoot, ".beads") + + // Load routes.jsonl + routes, err := beads.LoadRoutes(beadsDir) + if err != nil { + return &CheckResult{ + Name: c.Name(), + Status: StatusWarning, + Message: fmt.Sprintf("Could not load routes.jsonl: %v", err), + } + } + if len(routes) == 0 { + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: "No routes configured (nothing to check)", + } + } + + // Load rigs.json + rigsPath := filepath.Join(ctx.TownRoot, "mayor", "rigs.json") + rigsConfig, err := loadRigsConfig(rigsPath) + if err != nil { + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: "No rigs.json found (nothing to check)", + } + } + + // Build map of route path -> prefix from routes.jsonl + routePrefixByPath := make(map[string]string) + for _, r := range routes { + // Normalize: strip trailing hyphen from prefix for comparison + prefix := strings.TrimSuffix(r.Prefix, "-") + routePrefixByPath[r.Path] = prefix + } + + // Check each rig in rigs.json against routes.jsonl + var mismatches []string + mismatchData := make(map[string][2]string) // rigName -> [rigsJsonPrefix, routesPrefix] + + for rigName, rigEntry := range rigsConfig.Rigs { + // Skip rigs without beads config + if rigEntry.BeadsConfig == nil || rigEntry.BeadsConfig.Prefix == "" { + continue + } + + rigsJsonPrefix := rigEntry.BeadsConfig.Prefix + expectedPath := rigName + "/mayor/rig" + + // Find the route for this rig + routePrefix, hasRoute := routePrefixByPath[expectedPath] + if !hasRoute { + // No route for this rig - routes-config check handles this + continue + } + + // Compare prefixes (both should be without trailing hyphen) + if rigsJsonPrefix != routePrefix { + mismatches = append(mismatches, rigName) + mismatchData[rigName] = [2]string{rigsJsonPrefix, routePrefix} + } + } + + if len(mismatches) == 0 { + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: "No prefix mismatches found", + } + } + + // Build details + var details []string + for _, rigName := range mismatches { + data := mismatchData[rigName] + details = append(details, fmt.Sprintf("Rig '%s': rigs.json says '%s', routes.jsonl uses '%s'", + rigName, data[0], data[1])) + } + + return &CheckResult{ + Name: c.Name(), + Status: StatusWarning, + Message: fmt.Sprintf("%d prefix mismatch(es) between rigs.json and routes.jsonl", len(mismatches)), + Details: details, + FixHint: "Run 'gt doctor --fix' to update rigs.json with correct prefixes", + } +} + +// Fix updates rigs.json to match the prefixes in routes.jsonl. +func (c *PrefixMismatchCheck) Fix(ctx *CheckContext) error { + beadsDir := filepath.Join(ctx.TownRoot, ".beads") + + // Load routes.jsonl + routes, err := beads.LoadRoutes(beadsDir) + if err != nil || len(routes) == 0 { + return nil // Nothing to fix + } + + // Load rigs.json + rigsPath := filepath.Join(ctx.TownRoot, "mayor", "rigs.json") + rigsConfig, err := loadRigsConfig(rigsPath) + if err != nil { + return nil // Nothing to fix + } + + // Build map of route path -> prefix from routes.jsonl + routePrefixByPath := make(map[string]string) + for _, r := range routes { + prefix := strings.TrimSuffix(r.Prefix, "-") + routePrefixByPath[r.Path] = prefix + } + + // Update each rig's prefix to match routes.jsonl + modified := false + for rigName, rigEntry := range rigsConfig.Rigs { + expectedPath := rigName + "/mayor/rig" + routePrefix, hasRoute := routePrefixByPath[expectedPath] + if !hasRoute { + continue + } + + // Ensure BeadsConfig exists + if rigEntry.BeadsConfig == nil { + rigEntry.BeadsConfig = &rigsConfigBeadsConfig{} + } + + if rigEntry.BeadsConfig.Prefix != routePrefix { + rigEntry.BeadsConfig.Prefix = routePrefix + rigsConfig.Rigs[rigName] = rigEntry + modified = true + } + } + + if modified { + return saveRigsConfig(rigsPath, rigsConfig) + } + + return nil +} + +// rigsConfigEntry is a local type for loading rigs.json without importing config package +// to avoid circular dependencies and keep the check self-contained. +type rigsConfigEntry struct { + GitURL string `json:"git_url"` + LocalRepo string `json:"local_repo,omitempty"` + AddedAt string `json:"added_at"` // Keep as string to preserve format + BeadsConfig *rigsConfigBeadsConfig `json:"beads,omitempty"` +} + +type rigsConfigBeadsConfig struct { + Repo string `json:"repo"` + Prefix string `json:"prefix"` +} + +type rigsConfigFile struct { + Version int `json:"version"` + Rigs map[string]rigsConfigEntry `json:"rigs"` +} + +func loadRigsConfig(path string) (*rigsConfigFile, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var cfg rigsConfigFile + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, err + } + + return &cfg, nil +} + +func saveRigsConfig(path string, cfg *rigsConfigFile) error { + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + + return os.WriteFile(path, data, 0644) +} diff --git a/internal/doctor/beads_check_test.go b/internal/doctor/beads_check_test.go index 5df83808..667c879c 100644 --- a/internal/doctor/beads_check_test.go +++ b/internal/doctor/beads_check_test.go @@ -99,3 +99,219 @@ func TestBeadsDatabaseCheck_PopulatedDatabase(t *testing.T) { t.Errorf("expected StatusOK for populated db, got %v", result.Status) } } + +func TestNewPrefixMismatchCheck(t *testing.T) { + check := NewPrefixMismatchCheck() + + if check.Name() != "prefix-mismatch" { + t.Errorf("expected name 'prefix-mismatch', got %q", check.Name()) + } + + if !check.CanFix() { + t.Error("expected CanFix to return true") + } +} + +func TestPrefixMismatchCheck_NoRoutes(t *testing.T) { + tmpDir := t.TempDir() + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.MkdirAll(beadsDir, 0755); err != nil { + t.Fatal(err) + } + + check := NewPrefixMismatchCheck() + ctx := &CheckContext{TownRoot: tmpDir} + + result := check.Run(ctx) + + if result.Status != StatusOK { + t.Errorf("expected StatusOK for no routes, got %v", result.Status) + } +} + +func TestPrefixMismatchCheck_NoRigsJson(t *testing.T) { + tmpDir := t.TempDir() + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.MkdirAll(beadsDir, 0755); err != nil { + t.Fatal(err) + } + + // Create routes.jsonl + routesPath := filepath.Join(beadsDir, "routes.jsonl") + routesContent := `{"prefix":"gt-","path":"gastown/mayor/rig"}` + if err := os.WriteFile(routesPath, []byte(routesContent), 0644); err != nil { + t.Fatal(err) + } + + check := NewPrefixMismatchCheck() + ctx := &CheckContext{TownRoot: tmpDir} + + result := check.Run(ctx) + + if result.Status != StatusOK { + t.Errorf("expected StatusOK when no rigs.json, got %v", result.Status) + } +} + +func TestPrefixMismatchCheck_Matching(t *testing.T) { + tmpDir := t.TempDir() + beadsDir := filepath.Join(tmpDir, ".beads") + mayorDir := filepath.Join(tmpDir, "mayor") + if err := os.MkdirAll(beadsDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(mayorDir, 0755); err != nil { + t.Fatal(err) + } + + // Create routes.jsonl with gt- prefix + routesPath := filepath.Join(beadsDir, "routes.jsonl") + routesContent := `{"prefix":"gt-","path":"gastown/mayor/rig"}` + if err := os.WriteFile(routesPath, []byte(routesContent), 0644); err != nil { + t.Fatal(err) + } + + // Create rigs.json with matching gt prefix + rigsPath := filepath.Join(mayorDir, "rigs.json") + rigsContent := `{ + "version": 1, + "rigs": { + "gastown": { + "git_url": "https://github.com/example/gastown", + "beads": { + "prefix": "gt" + } + } + } + }` + if err := os.WriteFile(rigsPath, []byte(rigsContent), 0644); err != nil { + t.Fatal(err) + } + + check := NewPrefixMismatchCheck() + ctx := &CheckContext{TownRoot: tmpDir} + + result := check.Run(ctx) + + if result.Status != StatusOK { + t.Errorf("expected StatusOK for matching prefixes, got %v: %s", result.Status, result.Message) + } +} + +func TestPrefixMismatchCheck_Mismatch(t *testing.T) { + tmpDir := t.TempDir() + beadsDir := filepath.Join(tmpDir, ".beads") + mayorDir := filepath.Join(tmpDir, "mayor") + if err := os.MkdirAll(beadsDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(mayorDir, 0755); err != nil { + t.Fatal(err) + } + + // Create routes.jsonl with gt- prefix + routesPath := filepath.Join(beadsDir, "routes.jsonl") + routesContent := `{"prefix":"gt-","path":"gastown/mayor/rig"}` + if err := os.WriteFile(routesPath, []byte(routesContent), 0644); err != nil { + t.Fatal(err) + } + + // Create rigs.json with WRONG prefix (ga instead of gt) + rigsPath := filepath.Join(mayorDir, "rigs.json") + rigsContent := `{ + "version": 1, + "rigs": { + "gastown": { + "git_url": "https://github.com/example/gastown", + "beads": { + "prefix": "ga" + } + } + } + }` + if err := os.WriteFile(rigsPath, []byte(rigsContent), 0644); err != nil { + t.Fatal(err) + } + + check := NewPrefixMismatchCheck() + ctx := &CheckContext{TownRoot: tmpDir} + + result := check.Run(ctx) + + if result.Status != StatusWarning { + t.Errorf("expected StatusWarning for prefix mismatch, got %v: %s", result.Status, result.Message) + } + + if len(result.Details) != 1 { + t.Errorf("expected 1 detail, got %d", len(result.Details)) + } +} + +func TestPrefixMismatchCheck_Fix(t *testing.T) { + tmpDir := t.TempDir() + beadsDir := filepath.Join(tmpDir, ".beads") + mayorDir := filepath.Join(tmpDir, "mayor") + if err := os.MkdirAll(beadsDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(mayorDir, 0755); err != nil { + t.Fatal(err) + } + + // Create routes.jsonl with gt- prefix + routesPath := filepath.Join(beadsDir, "routes.jsonl") + routesContent := `{"prefix":"gt-","path":"gastown/mayor/rig"}` + if err := os.WriteFile(routesPath, []byte(routesContent), 0644); err != nil { + t.Fatal(err) + } + + // Create rigs.json with WRONG prefix (ga instead of gt) + rigsPath := filepath.Join(mayorDir, "rigs.json") + rigsContent := `{ + "version": 1, + "rigs": { + "gastown": { + "git_url": "https://github.com/example/gastown", + "beads": { + "prefix": "ga" + } + } + } + }` + if err := os.WriteFile(rigsPath, []byte(rigsContent), 0644); err != nil { + t.Fatal(err) + } + + check := NewPrefixMismatchCheck() + ctx := &CheckContext{TownRoot: tmpDir} + + // First verify there's a mismatch + result := check.Run(ctx) + if result.Status != StatusWarning { + t.Fatalf("expected mismatch before fix, got %v", result.Status) + } + + // Fix it + if err := check.Fix(ctx); err != nil { + t.Fatalf("Fix() failed: %v", err) + } + + // Verify it's now fixed + result = check.Run(ctx) + if result.Status != StatusOK { + t.Errorf("expected StatusOK after fix, got %v: %s", result.Status, result.Message) + } + + // Verify rigs.json was updated + data, err := os.ReadFile(rigsPath) + if err != nil { + t.Fatal(err) + } + cfg, err := loadRigsConfig(rigsPath) + if err != nil { + t.Fatalf("failed to load fixed rigs.json: %v (content: %s)", err, data) + } + if cfg.Rigs["gastown"].BeadsConfig.Prefix != "gt" { + t.Errorf("expected prefix 'gt' after fix, got %q", cfg.Rigs["gastown"].BeadsConfig.Prefix) + } +}