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>
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// setupTestRegistry creates a minimal registry.db with pc_locations + apps tables
|
||||
// in a temp directory and returns the root path + a cleanup func.
|
||||
func setupTestRegistry(t *testing.T) (string, func()) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
|
||||
dbPath := filepath.Join(root, "registry.db")
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open test db: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE pc_locations (
|
||||
id TEXT PRIMARY KEY,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id TEXT NOT NULL,
|
||||
pc_id TEXT NOT NULL,
|
||||
dir_path TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
notes TEXT,
|
||||
created_at TEXT,
|
||||
updated_at TEXT
|
||||
);
|
||||
CREATE TABLE apps (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
dir_path TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE TABLE analysis (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
dir_path TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("create schema: %v", err)
|
||||
}
|
||||
|
||||
return root, func() {}
|
||||
}
|
||||
|
||||
func insertLocation(t *testing.T, root, entityType, entityID, dirPath, status, pcID string) {
|
||||
t.Helper()
|
||||
db, _ := sql.Open("sqlite3", filepath.Join(root, "registry.db"))
|
||||
defer db.Close()
|
||||
id := entityType + "_" + entityID + "_" + pcID
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO pc_locations(id, entity_type, entity_id, pc_id, dir_path, status) VALUES (?,?,?,?,?,?)`,
|
||||
id, entityType, entityID, pcID, dirPath, status,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert location: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func insertApp(t *testing.T, root, id, dirPath string) {
|
||||
t.Helper()
|
||||
db, _ := sql.Open("sqlite3", filepath.Join(root, "registry.db"))
|
||||
defer db.Close()
|
||||
_, err := db.Exec(`INSERT INTO apps(id, dir_path) VALUES (?,?)`, id, dirPath)
|
||||
if err != nil {
|
||||
t.Fatalf("insert app: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPcLocationsDrift_DetectsMissingFolder(t *testing.T) {
|
||||
root, cleanup := setupTestRegistry(t)
|
||||
defer cleanup()
|
||||
|
||||
// An active entry whose folder does NOT exist on disk
|
||||
insertLocation(t, root, "app", "my_app", "apps/my_app", "active", "test-pc")
|
||||
|
||||
drifts, err := PcLocationsDrift(root, "test-pc")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, d := range drifts {
|
||||
if d.EntityID == "my_app" && d.Issue == "missing_on_disk" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected missing_on_disk drift for my_app, got: %+v", drifts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPcLocationsDrift_ActiveFolderExistsNoDrift(t *testing.T) {
|
||||
root, cleanup := setupTestRegistry(t)
|
||||
defer cleanup()
|
||||
|
||||
// Create the folder
|
||||
appDir := filepath.Join(root, "apps", "good_app")
|
||||
if err := os.MkdirAll(appDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
insertLocation(t, root, "app", "good_app", "apps/good_app", "active", "test-pc")
|
||||
|
||||
drifts, err := PcLocationsDrift(root, "test-pc")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
for _, d := range drifts {
|
||||
if d.EntityID == "good_app" {
|
||||
t.Errorf("unexpected drift for good_app: %+v", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPcLocationsDrift_StatusShouldBeActive(t *testing.T) {
|
||||
root, cleanup := setupTestRegistry(t)
|
||||
defer cleanup()
|
||||
|
||||
// Folder exists but status is "missing"
|
||||
appDir := filepath.Join(root, "apps", "came_back")
|
||||
if err := os.MkdirAll(appDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
insertLocation(t, root, "app", "came_back", "apps/came_back", "missing", "test-pc")
|
||||
|
||||
drifts, err := PcLocationsDrift(root, "test-pc")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, d := range drifts {
|
||||
if d.EntityID == "came_back" && d.Issue == "status_should_be_active" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected status_should_be_active for came_back, got: %+v", drifts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPcLocationsDrift_UntrackedOnDisk(t *testing.T) {
|
||||
root, cleanup := setupTestRegistry(t)
|
||||
defer cleanup()
|
||||
|
||||
// App indexed in registry with folder on disk but no pc_locations entry
|
||||
appDir := filepath.Join(root, "apps", "orphan_app")
|
||||
if err := os.MkdirAll(appDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
insertApp(t, root, "orphan_app", "apps/orphan_app")
|
||||
// No pc_locations entry for test-pc
|
||||
|
||||
drifts, err := PcLocationsDrift(root, "test-pc")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, d := range drifts {
|
||||
if d.EntityID == "orphan_app" && d.Issue == "untracked_on_disk" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected untracked_on_disk for orphan_app, got: %+v", drifts)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user