625569485f
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>
186 lines
4.8 KiB
Go
186 lines
4.8 KiB
Go
package infra
|
|
|
|
import (
|
|
"bufio"
|
|
"database/sql"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
_ "github.com/mattn/go-sqlite3"
|
|
)
|
|
|
|
// LocationDrift describes a discrepancy between pc_locations and the real disk state.
|
|
type LocationDrift struct {
|
|
EntityType string // app, analysis, project, vault
|
|
EntityID string // id of the artefact
|
|
DirPath string // dir_path registered or detected
|
|
Status string // value in pc_locations (active/missing/archived) or "" if not registered
|
|
Issue string // "missing_on_disk" | "untracked_on_disk" | "status_should_be_active"
|
|
}
|
|
|
|
// PcLocationsDrift compares pc_locations entries against real disk state for pcID.
|
|
// If pcID is empty it is read from the first non-empty line of ~/.fn_pc.
|
|
// Returns a slice of drift items (never nil, may be empty).
|
|
func PcLocationsDrift(registryRoot string, pcID string) ([]LocationDrift, error) {
|
|
if pcID == "" {
|
|
id, err := readFnPC()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("pc_locations_drift: cannot determine pcID: %w", err)
|
|
}
|
|
pcID = id
|
|
}
|
|
|
|
dbPath := filepath.Join(registryRoot, "registry.db")
|
|
db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=on")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("pc_locations_drift: open registry.db: %w", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
// Query A: registered locations for this PC
|
|
rows, err := db.Query(
|
|
`SELECT entity_type, entity_id, dir_path, status FROM pc_locations WHERE pc_id = ?`,
|
|
pcID,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("pc_locations_drift: query pc_locations: %w", err)
|
|
}
|
|
|
|
type locRow struct {
|
|
entityType string
|
|
entityID string
|
|
dirPath string
|
|
status string
|
|
}
|
|
|
|
var registered []locRow
|
|
registeredKey := map[string]locRow{} // key: entityType+"/"+entityID
|
|
|
|
for rows.Next() {
|
|
var r locRow
|
|
if err := rows.Scan(&r.entityType, &r.entityID, &r.dirPath, &r.status); err != nil {
|
|
rows.Close()
|
|
return nil, fmt.Errorf("pc_locations_drift: scan: %w", err)
|
|
}
|
|
registered = append(registered, r)
|
|
registeredKey[r.entityType+"/"+r.entityID] = r
|
|
}
|
|
rows.Close()
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("pc_locations_drift: rows: %w", err)
|
|
}
|
|
|
|
drifts := []LocationDrift{}
|
|
|
|
// Check registered entries against disk
|
|
for _, r := range registered {
|
|
fullPath := r.dirPath
|
|
if !filepath.IsAbs(fullPath) {
|
|
fullPath = filepath.Join(registryRoot, fullPath)
|
|
}
|
|
exists := dirExists(fullPath)
|
|
|
|
if r.status == "active" && !exists {
|
|
drifts = append(drifts, LocationDrift{
|
|
EntityType: r.entityType,
|
|
EntityID: r.entityID,
|
|
DirPath: r.dirPath,
|
|
Status: r.status,
|
|
Issue: "missing_on_disk",
|
|
})
|
|
} else if r.status == "missing" && exists {
|
|
drifts = append(drifts, LocationDrift{
|
|
EntityType: r.entityType,
|
|
EntityID: r.entityID,
|
|
DirPath: r.dirPath,
|
|
Status: r.status,
|
|
Issue: "status_should_be_active",
|
|
})
|
|
}
|
|
}
|
|
|
|
// Query B: all indexed artefacts (apps + analysis) with dir_path
|
|
type artefact struct {
|
|
entityType string
|
|
id string
|
|
dirPath string
|
|
}
|
|
var artefacts []artefact
|
|
|
|
for _, q := range []struct {
|
|
table string
|
|
entityType string
|
|
}{
|
|
{"apps", "app"},
|
|
{"analysis", "analysis"},
|
|
} {
|
|
arows, err := db.Query(fmt.Sprintf(`SELECT id, dir_path FROM %s WHERE dir_path != ''`, q.table))
|
|
if err != nil {
|
|
// Table may not exist in all registry versions; skip gracefully
|
|
continue
|
|
}
|
|
for arows.Next() {
|
|
var a artefact
|
|
a.entityType = q.entityType
|
|
if err := arows.Scan(&a.id, &a.dirPath); err != nil {
|
|
arows.Close()
|
|
return nil, fmt.Errorf("pc_locations_drift: scan %s: %w", q.table, err)
|
|
}
|
|
artefacts = append(artefacts, a)
|
|
}
|
|
arows.Close()
|
|
}
|
|
|
|
// Cross: indexed artefact on disk but not in pc_locations for pcID
|
|
for _, a := range artefacts {
|
|
fullPath := a.dirPath
|
|
if !filepath.IsAbs(fullPath) {
|
|
fullPath = filepath.Join(registryRoot, fullPath)
|
|
}
|
|
if !dirExists(fullPath) {
|
|
continue // not on this machine, that's fine
|
|
}
|
|
key := a.entityType + "/" + a.id
|
|
if _, found := registeredKey[key]; !found {
|
|
drifts = append(drifts, LocationDrift{
|
|
EntityType: a.entityType,
|
|
EntityID: a.id,
|
|
DirPath: a.dirPath,
|
|
Status: "",
|
|
Issue: "untracked_on_disk",
|
|
})
|
|
}
|
|
}
|
|
|
|
return drifts, nil
|
|
}
|
|
|
|
// readFnPC reads the first non-empty, non-comment line from ~/.fn_pc.
|
|
func readFnPC() (string, error) {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
f, err := os.Open(filepath.Join(home, ".fn_pc"))
|
|
if err != nil {
|
|
return "", fmt.Errorf("~/.fn_pc not found: %w", err)
|
|
}
|
|
defer f.Close()
|
|
sc := bufio.NewScanner(f)
|
|
for sc.Scan() {
|
|
line := strings.TrimSpace(sc.Text())
|
|
if line != "" && !strings.HasPrefix(line, "#") {
|
|
return line, nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("~/.fn_pc is empty")
|
|
}
|
|
|
|
// dirExists returns true if path is an existing directory.
|
|
func dirExists(path string) bool {
|
|
info, err := os.Stat(path)
|
|
return err == nil && info.IsDir()
|
|
}
|