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"
|
||||
)
|
||||
|
||||
// createTestRegistryDB creates a minimal registry.db with the given apps and
|
||||
// a single function (random_hex_id_go_core in domain core, lang go).
|
||||
func createTestRegistryDB(t *testing.T, root string, apps []struct {
|
||||
id string
|
||||
lang string
|
||||
dirPath string
|
||||
usesFunctions string
|
||||
}) {
|
||||
t.Helper()
|
||||
dbPath := filepath.Join(root, "registry.db")
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE functions (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
domain TEXT,
|
||||
lang TEXT,
|
||||
file_path TEXT
|
||||
);
|
||||
CREATE TABLE apps (
|
||||
id TEXT PRIMARY KEY,
|
||||
lang TEXT,
|
||||
dir_path TEXT,
|
||||
uses_functions TEXT DEFAULT '[]'
|
||||
);
|
||||
INSERT INTO functions (id, name, domain, lang, file_path)
|
||||
VALUES ('random_hex_id_go_core','random_hex_id','core','go','functions/core/random_hex_id.go');
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, a := range apps {
|
||||
_, err = db.Exec(
|
||||
`INSERT INTO apps (id, lang, dir_path, uses_functions) VALUES (?,?,?,?)`,
|
||||
a.id, a.lang, a.dirPath, a.usesFunctions,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert app %s: %v", a.id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuditUsesFunctions_DetectsMissing verifies that a Go app that calls
|
||||
// RandomHexID in its source but declares empty uses_functions gets
|
||||
// random_hex_id_go_core reported as missing.
|
||||
func TestAuditUsesFunctions_DetectsMissing(t *testing.T) {
|
||||
t.Run("missing function detected for Go app", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
createTestRegistryDB(t, root, []struct {
|
||||
id, lang, dirPath, usesFunctions string
|
||||
}{
|
||||
{"testapp_go_tools", "go", "apps/testapp", `[]`},
|
||||
})
|
||||
|
||||
appDir := filepath.Join(root, "apps", "testapp")
|
||||
if err := os.MkdirAll(appDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
goSrc := `package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"fn-registry/functions/core"
|
||||
)
|
||||
|
||||
func main() {
|
||||
id := core.RandomHexID(8)
|
||||
fmt.Println(id)
|
||||
}
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(appDir, "main.go"), []byte(goSrc), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
results, err := AuditUsesFunctions(root)
|
||||
if err != nil {
|
||||
t.Fatalf("AuditUsesFunctions: %v", err)
|
||||
}
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("expected 1 result, got %d", len(results))
|
||||
}
|
||||
got := results[0]
|
||||
if len(got.Missing) != 1 || got.Missing[0] != "random_hex_id_go_core" {
|
||||
t.Errorf("Missing = %v, want [random_hex_id_go_core]", got.Missing)
|
||||
}
|
||||
if len(got.Unused) != 0 {
|
||||
t.Errorf("Unused = %v, want []", got.Unused)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestAuditUsesFunctions_DetectsUnused verifies that a function declared in
|
||||
// uses_functions but not called in source is reported as unused.
|
||||
func TestAuditUsesFunctions_DetectsUnused(t *testing.T) {
|
||||
t.Run("unused function detected for Go app", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
createTestRegistryDB(t, root, []struct {
|
||||
id, lang, dirPath, usesFunctions string
|
||||
}{
|
||||
{"testapp2_go_tools", "go", "apps/testapp2", `["random_hex_id_go_core"]`},
|
||||
})
|
||||
|
||||
appDir := filepath.Join(root, "apps", "testapp2")
|
||||
if err := os.MkdirAll(appDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
goSrc := `package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func main() { fmt.Println("hello") }
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(appDir, "main.go"), []byte(goSrc), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
results, err := AuditUsesFunctions(root)
|
||||
if err != nil {
|
||||
t.Fatalf("AuditUsesFunctions: %v", err)
|
||||
}
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("expected 1 result, got %d", len(results))
|
||||
}
|
||||
got := results[0]
|
||||
if len(got.Unused) != 1 || got.Unused[0] != "random_hex_id_go_core" {
|
||||
t.Errorf("Unused = %v, want [random_hex_id_go_core]", got.Unused)
|
||||
}
|
||||
if len(got.Missing) != 0 {
|
||||
t.Errorf("Missing = %v, want []", got.Missing)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestAuditUsesFunctions_MissingDir verifies that apps whose dir_path does not
|
||||
// exist on disk get an entry with nil Missing/Unused slices (cannot inspect).
|
||||
func TestAuditUsesFunctions_MissingDir(t *testing.T) {
|
||||
t.Run("missing dir returns entry with nil slices", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
createTestRegistryDB(t, root, []struct {
|
||||
id, lang, dirPath, usesFunctions string
|
||||
}{
|
||||
{"testapp3_go_tools", "go", "apps/testapp3", `[]`},
|
||||
})
|
||||
// intentionally do NOT create apps/testapp3 on disk
|
||||
|
||||
results, err := AuditUsesFunctions(root)
|
||||
if err != nil {
|
||||
t.Fatalf("AuditUsesFunctions: %v", err)
|
||||
}
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("expected 1 result, got %d", len(results))
|
||||
}
|
||||
got := results[0]
|
||||
if got.Missing != nil {
|
||||
t.Errorf("Missing should be nil for missing dir, got %v", got.Missing)
|
||||
}
|
||||
if got.Unused != nil {
|
||||
t.Errorf("Unused should be nil for missing dir, got %v", got.Unused)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user