Files
fn_registry/functions/infra/pc_locations_drift.go
egutierrez 2a3d780347 feat(doctor): add fn doctor CLI + 14 functions for system management
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>
2026-05-07 01:42:10 +02:00

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()
}