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,341 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// UsesFunctionsAudit holds the drift report for a single app.
|
||||
type UsesFunctionsAudit struct {
|
||||
AppID string // registry id, e.g. "kanban_go_tools"
|
||||
Lang string // "go" or "py"
|
||||
DirPath string // dir_path as stored in registry.db
|
||||
Missing []string // function IDs found in imports but absent from app.md uses_functions
|
||||
Unused []string // function IDs declared in app.md but not detected in code
|
||||
}
|
||||
|
||||
// auditFnMeta holds registry metadata for a single function.
|
||||
type auditFnMeta struct {
|
||||
id string
|
||||
name string
|
||||
domain string
|
||||
lang string
|
||||
}
|
||||
|
||||
// AuditUsesFunctions checks every Go and Python app registered in registry.db
|
||||
// and compares the uses_functions declared in the app.md manifest against the
|
||||
// functions actually imported by the app's source code.
|
||||
//
|
||||
// For Go apps it greps for "fn-registry/functions/<domain>" import paths, then
|
||||
// searches the source for the exported symbol derived from each function name
|
||||
// (snake_case → PascalCase) to achieve per-function granularity within a package.
|
||||
//
|
||||
// For Python apps it scans for "from <pkg> import X" patterns where <pkg> matches
|
||||
// a known registry domain, then resolves X to a function ID by matching the name
|
||||
// field in registry.db.
|
||||
//
|
||||
// Returns an error only if registry.db cannot be opened. Apps where dir_path
|
||||
// does not exist on disk are reported with Missing/Unused = nil (cannot inspect).
|
||||
func AuditUsesFunctions(registryRoot string) ([]UsesFunctionsAudit, 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("audit_uses_functions: open db: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
if err := db.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("audit_uses_functions: ping db: %w", err)
|
||||
}
|
||||
|
||||
// Load all Go/Python functions from registry: id → name, domain, lang.
|
||||
rows, err := db.Query(`SELECT id, name, domain, lang FROM functions WHERE lang IN ('go','py')`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("audit_uses_functions: query functions: %w", err)
|
||||
}
|
||||
allFunctions := make(map[string]auditFnMeta) // id → meta
|
||||
for rows.Next() {
|
||||
var m auditFnMeta
|
||||
if err := rows.Scan(&m.id, &m.name, &m.domain, &m.lang); err != nil {
|
||||
continue
|
||||
}
|
||||
allFunctions[m.id] = m
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
// Load apps with lang go or py.
|
||||
type appRow struct {
|
||||
id string
|
||||
lang string
|
||||
dirPath string
|
||||
usesFunctions []string
|
||||
}
|
||||
rows2, err := db.Query(`SELECT id, lang, dir_path, uses_functions FROM apps WHERE lang IN ('go','py')`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("audit_uses_functions: query apps: %w", err)
|
||||
}
|
||||
var apps []appRow
|
||||
for rows2.Next() {
|
||||
var a appRow
|
||||
var ufJSON string
|
||||
if err := rows2.Scan(&a.id, &a.lang, &a.dirPath, &ufJSON); err != nil {
|
||||
continue
|
||||
}
|
||||
_ = json.Unmarshal([]byte(ufJSON), &a.usesFunctions)
|
||||
apps = append(apps, a)
|
||||
}
|
||||
rows2.Close()
|
||||
|
||||
var results []UsesFunctionsAudit
|
||||
for _, app := range apps {
|
||||
absDir := app.dirPath
|
||||
if !filepath.IsAbs(absDir) {
|
||||
absDir = filepath.Join(registryRoot, app.dirPath)
|
||||
}
|
||||
audit := UsesFunctionsAudit{
|
||||
AppID: app.id,
|
||||
Lang: app.lang,
|
||||
DirPath: app.dirPath,
|
||||
}
|
||||
|
||||
if _, err := os.Stat(absDir); os.IsNotExist(err) {
|
||||
// Cannot inspect — skip diff, leave Missing/Unused nil.
|
||||
results = append(results, audit)
|
||||
continue
|
||||
}
|
||||
|
||||
var importedIDs []string
|
||||
switch app.lang {
|
||||
case "go":
|
||||
importedIDs = auditGoApp(absDir, allFunctions)
|
||||
case "py":
|
||||
importedIDs = auditPyApp(absDir, allFunctions)
|
||||
}
|
||||
|
||||
declaredSet := make(map[string]bool)
|
||||
for _, id := range app.usesFunctions {
|
||||
declaredSet[id] = true
|
||||
}
|
||||
importedSet := make(map[string]bool)
|
||||
for _, id := range importedIDs {
|
||||
importedSet[id] = true
|
||||
}
|
||||
|
||||
for id := range importedSet {
|
||||
if !declaredSet[id] {
|
||||
audit.Missing = append(audit.Missing, id)
|
||||
}
|
||||
}
|
||||
for id := range declaredSet {
|
||||
if !importedSet[id] {
|
||||
audit.Unused = append(audit.Unused, id)
|
||||
}
|
||||
}
|
||||
|
||||
results = append(results, audit)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// auditGoApp returns function IDs detected in the Go source files of appDir.
|
||||
// Strategy:
|
||||
// 1. Find all "fn-registry/functions/<domain>" import paths.
|
||||
// 2. For each domain, collect registry functions in that domain.
|
||||
// 3. Grep source files for the exported symbol (PascalCase of name).
|
||||
// If any source file contains the token, the function is considered used.
|
||||
func auditGoApp(appDir string, all map[string]auditFnMeta) []string {
|
||||
// Step 1: collect imported domains.
|
||||
importedDomains := collectGoImportedDomains(appDir)
|
||||
if len(importedDomains) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Step 2: for each function in those domains, grep for its exported name.
|
||||
var used []string
|
||||
// Read all .go source once into a single blob for fast search.
|
||||
blob := readGoSourceBlob(appDir)
|
||||
if blob == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, m := range all {
|
||||
if m.lang != "go" {
|
||||
continue
|
||||
}
|
||||
if !importedDomains[m.domain] {
|
||||
continue
|
||||
}
|
||||
exported := snakeToPascal(m.name)
|
||||
// Use word-boundary-like check: look for the token as a standalone identifier.
|
||||
// We check the domain qualifier pattern e.g. "infra.SQLiteOpen" or bare "SQLiteOpen(".
|
||||
if containsToken(blob, exported) {
|
||||
used = append(used, m.id)
|
||||
}
|
||||
}
|
||||
return used
|
||||
}
|
||||
|
||||
// collectGoImportedDomains returns the set of registry domains imported by .go files.
|
||||
var goImportRe = regexp.MustCompile(`"fn-registry/functions/([a-z]+)"`)
|
||||
|
||||
func collectGoImportedDomains(appDir string) map[string]bool {
|
||||
domains := make(map[string]bool)
|
||||
_ = filepath.Walk(appDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() || !strings.HasSuffix(path, ".go") {
|
||||
return nil
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer f.Close()
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
if m := goImportRe.FindStringSubmatch(line); m != nil {
|
||||
domains[m[1]] = true
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return domains
|
||||
}
|
||||
|
||||
// readGoSourceBlob concatenates all .go file contents in appDir.
|
||||
func readGoSourceBlob(appDir string) string {
|
||||
var sb strings.Builder
|
||||
_ = filepath.Walk(appDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() || !strings.HasSuffix(path, ".go") {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
sb.Write(data)
|
||||
sb.WriteByte('\n')
|
||||
return nil
|
||||
})
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// containsToken reports whether the exported symbol appears as an identifier
|
||||
// in src (preceded and followed by non-letter/non-digit/non-underscore runes,
|
||||
// or at string boundaries). This avoids matching substrings inside longer names.
|
||||
func containsToken(src, token string) bool {
|
||||
idx := 0
|
||||
for {
|
||||
pos := strings.Index(src[idx:], token)
|
||||
if pos < 0 {
|
||||
return false
|
||||
}
|
||||
abs := idx + pos
|
||||
before := abs == 0 || !isIdentRune(rune(src[abs-1]))
|
||||
after := abs+len(token) >= len(src) || !isIdentRune(rune(src[abs+len(token)]))
|
||||
if before && after {
|
||||
return true
|
||||
}
|
||||
idx = abs + 1
|
||||
}
|
||||
}
|
||||
|
||||
func isIdentRune(r rune) bool {
|
||||
return r == '_' || unicode.IsLetter(r) || unicode.IsDigit(r)
|
||||
}
|
||||
|
||||
// auditPyApp returns function IDs detected in the Python source of appDir.
|
||||
// Looks for: "from <pkg> import X, Y" patterns and resolves X, Y to function IDs.
|
||||
var pyFromImportRe = regexp.MustCompile(`from\s+(\w+)\s+import\s+(.+)`)
|
||||
|
||||
func auditPyApp(appDir string, all map[string]auditFnMeta) []string {
|
||||
// Build name→id map for py functions.
|
||||
nameToID := make(map[string]string) // "metabase_auth" → "metabase_auth_py_infra"
|
||||
for _, m := range all {
|
||||
if m.lang == "py" {
|
||||
nameToID[m.name] = m.id
|
||||
}
|
||||
}
|
||||
|
||||
usedSet := make(map[string]bool)
|
||||
|
||||
_ = filepath.Walk(appDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() || !strings.HasSuffix(path, ".py") {
|
||||
return nil
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer f.Close()
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if m := pyFromImportRe.FindStringSubmatch(line); m != nil {
|
||||
// m[2] = "X, Y, Z" or "X"
|
||||
names := strings.Split(m[2], ",")
|
||||
for _, nm := range names {
|
||||
nm = strings.TrimSpace(nm)
|
||||
nm = strings.Fields(nm)[0] // strip "as alias"
|
||||
if id, ok := nameToID[nm]; ok {
|
||||
usedSet[id] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
var used []string
|
||||
for id := range usedSet {
|
||||
used = append(used, id)
|
||||
}
|
||||
return used
|
||||
}
|
||||
|
||||
// snakeToPascal converts snake_case to PascalCase (Go exported name).
|
||||
// E.g. "sqlite_open" → "SQLiteOpen", "http_json_response" → "HTTPJSONResponse".
|
||||
// Common abbreviations are uppercased in full.
|
||||
var commonAbbrevs = map[string]string{
|
||||
"http": "HTTP",
|
||||
"sql": "SQL",
|
||||
"url": "URL",
|
||||
"api": "API",
|
||||
"id": "ID",
|
||||
"db": "DB",
|
||||
"tls": "TLS",
|
||||
"json": "JSON",
|
||||
"xml": "XML",
|
||||
"ssh": "SSH",
|
||||
"cmd": "Cmd",
|
||||
"ctx": "Ctx",
|
||||
"cfg": "Cfg",
|
||||
"env": "Env",
|
||||
"io": "IO",
|
||||
"ok": "OK",
|
||||
"ui": "UI",
|
||||
}
|
||||
|
||||
func snakeToPascal(s string) string {
|
||||
parts := strings.Split(s, "_")
|
||||
var sb strings.Builder
|
||||
for _, p := range parts {
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if abbr, ok := commonAbbrevs[strings.ToLower(p)]; ok {
|
||||
sb.WriteString(abbr)
|
||||
} else {
|
||||
sb.WriteString(strings.ToUpper(p[:1]) + p[1:])
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
Reference in New Issue
Block a user