package infra import ( "bufio" "bytes" "context" "database/sql" "fmt" "os" "os/exec" "path/filepath" "strings" "time" _ "github.com/mattn/go-sqlite3" ) // ArtefactCheck holds the health report for a single artefact (app or analysis). type ArtefactCheck struct { ID string // registry id, e.g. "kanban_go_tools" Type string // "app" or "analysis" DirPath string // dir_path as stored in registry.db Issues []string // human-readable problems; empty means healthy OK bool // true when len(Issues) == 0 } // ArtefactDoctor audits every app and analysis registered in registry.db. // It checks disk presence, git initialisation, manifest parseability, venv // health (analyses only) and upstream branch configuration. // The function is read-only: it never modifies any file or database. // Returns an error only if registry.db cannot be opened. func ArtefactDoctor(registryRoot string) ([]ArtefactCheck, error) { dbPath := filepath.Join(registryRoot, "registry.db") dsn := fmt.Sprintf("file:%s?mode=ro&_foreign_keys=on", dbPath) db, err := sql.Open("sqlite3", dsn) if err != nil { return nil, fmt.Errorf("artefact_doctor: open db: %w", err) } defer db.Close() if err := db.Ping(); err != nil { return nil, fmt.Errorf("artefact_doctor: ping db: %w", err) } var checks []ArtefactCheck rows, err := db.Query("SELECT id, dir_path FROM apps") if err != nil { return nil, fmt.Errorf("artefact_doctor: query apps: %w", err) } defer rows.Close() for rows.Next() { var id, dirPath string if err := rows.Scan(&id, &dirPath); err != nil { continue } checks = append(checks, checkArtefact(id, "app", dirPath, registryRoot)) } rows2, err := db.Query("SELECT id, dir_path FROM analysis") if err != nil { return nil, fmt.Errorf("artefact_doctor: query analysis: %w", err) } defer rows2.Close() for rows2.Next() { var id, dirPath string if err := rows2.Scan(&id, &dirPath); err != nil { continue } checks = append(checks, checkArtefact(id, "analysis", dirPath, registryRoot)) } return checks, nil } func checkArtefact(id, kind, dirPath, registryRoot string) ArtefactCheck { c := ArtefactCheck{ID: id, Type: kind, DirPath: dirPath} absDir := filepath.Join(registryRoot, dirPath) // 1. Directory exists if _, err := os.Stat(absDir); os.IsNotExist(err) { c.Issues = append(c.Issues, "directory_missing") c.OK = len(c.Issues) == 0 return c } // 2. .git present gitPath := filepath.Join(absDir, ".git") if _, err := os.Stat(gitPath); os.IsNotExist(err) { c.Issues = append(c.Issues, "git_not_initialized") } // 3. Manifest parseable var mdFile string switch kind { case "app": mdFile = "app.md" case "analysis": mdFile = "analysis.md" } mdPath := filepath.Join(absDir, mdFile) if _, err := os.Stat(mdPath); os.IsNotExist(err) { c.Issues = append(c.Issues, mdFile[:len(mdFile)-3]+"_md_missing") } else if !frontmatterHasName(mdPath) { c.Issues = append(c.Issues, mdFile[:len(mdFile)-3]+"_md_invalid_frontmatter") } // 4. Analysis venv check if kind == "analysis" { python3 := filepath.Join(absDir, ".venv", "bin", "python3") fi, err := os.Lstat(python3) if os.IsNotExist(err) { c.Issues = append(c.Issues, "venv_missing") } else if err == nil { if fi.Mode()&os.ModeSymlink != 0 { target, lerr := os.Readlink(python3) if lerr == nil { if !filepath.IsAbs(target) { target = filepath.Join(filepath.Dir(python3), target) } if _, serr := os.Stat(target); os.IsNotExist(serr) { c.Issues = append(c.Issues, "venv_broken_path") } } } else if fi.Mode().Perm()&0o111 == 0 { c.Issues = append(c.Issues, "venv_broken_path") } } } // 5. Upstream branch (only if .git exists) if _, err := os.Stat(gitPath); err == nil { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() cmd := exec.CommandContext(ctx, "git", "-C", absDir, "rev-parse", "--abbrev-ref", "@{u}") var out bytes.Buffer cmd.Stdout = &out cmd.Stderr = &out if err := cmd.Run(); err != nil { c.Issues = append(c.Issues, "no_upstream_branch") } } c.OK = len(c.Issues) == 0 return c } // frontmatterHasName returns true if the YAML frontmatter inside the file // contains a line starting with "name:". func frontmatterHasName(path string) bool { f, err := os.Open(path) if err != nil { return false } defer f.Close() scanner := bufio.NewScanner(f) inFrontmatter := false for scanner.Scan() { line := scanner.Text() if line == "---" { if !inFrontmatter { inFrontmatter = true continue } break // closing --- } if inFrontmatter && strings.HasPrefix(line, "name:") { return true } } return false }