2a3d780347
Adds `fn doctor` read-only diagnostic command with subcommands artefacts, services, sync, uses-functions, unused, and --json flag for agents. Each subcommand wraps a registry function in functions/infra/. New functions: - artefact_doctor, services_status, pc_locations_drift, audit_uses_functions, find_unused_functions (Go diagnostics) - backup_sqlite_db, rotate_backups, wait_for_http, wait_for_port, port_kill, tail_journal, pre_commit_hook_install (bash utilities) - notify_telegram (Go HTTP) - backup_all pipeline (tag launcher) Plus prior session leftovers (scan_secrets_in_dirty, append_diary_entry, git utilities, http_session_cookie_middleware, compile/full-git pipelines). Fixes pc_locations_drift filepath.Join bug with absolute dir_path. Documents fn doctor in CLAUDE.md, .claude/rules/fn_doctor.md (rule 23), docs/architecture.md, CHANGELOG.md (2026-05-07), and diary entry. First fn doctor uses-functions run found drift in 7/12 apps (deuda para sincronizar app.md con imports reales). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
173 lines
4.6 KiB
Go
173 lines
4.6 KiB
Go
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
|
|
}
|