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,149 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// UnusedFunction represents a registry function with no known consumers.
|
||||
type UnusedFunction struct {
|
||||
ID string
|
||||
Name string
|
||||
Lang string
|
||||
Domain string
|
||||
Tags string // JSON array string, useful for detecting "deprecated" tags
|
||||
AgeDays int // days since updated_at
|
||||
}
|
||||
|
||||
// FindUnusedFunctions opens <registryRoot>/registry.db and returns all
|
||||
// functions that are not referenced by any other function, app, or analysis
|
||||
// via their uses_functions field.
|
||||
//
|
||||
// Pipelines with the "launcher" tag are included if nobody calls them —
|
||||
// they are endpoint-only but still candidates if unlaunched and uncalled.
|
||||
// Plain pipelines (kind = "pipeline", no "launcher" tag) are also included.
|
||||
// Functions with kind = "pipeline" that have the "launcher" tag are excluded
|
||||
// because they are intentionally terminal consumers.
|
||||
func FindUnusedFunctions(registryRoot string) ([]UnusedFunction, error) {
|
||||
dbPath := registryRoot + "/registry.db"
|
||||
db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find_unused_functions: open db: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Build the set of used IDs from uses_functions across functions, apps, and analyses.
|
||||
usedIDs := make(map[string]struct{})
|
||||
|
||||
type usesRow struct {
|
||||
usesJSON string
|
||||
}
|
||||
|
||||
collectUsed := func(query string) error {
|
||||
rows, err := db.Query(query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var raw string
|
||||
if err := rows.Scan(&raw); err != nil {
|
||||
return err
|
||||
}
|
||||
var ids []string
|
||||
if err := json.Unmarshal([]byte(raw), &ids); err != nil {
|
||||
continue // malformed JSON, skip
|
||||
}
|
||||
for _, id := range ids {
|
||||
id = strings.TrimSpace(id)
|
||||
if id != "" {
|
||||
usedIDs[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
queries := []string{
|
||||
"SELECT uses_functions FROM functions WHERE uses_functions != '[]'",
|
||||
"SELECT uses_functions FROM apps WHERE uses_functions != '[]'",
|
||||
"SELECT uses_functions FROM analysis WHERE uses_functions != '[]'",
|
||||
}
|
||||
for _, q := range queries {
|
||||
if err := collectUsed(q); err != nil {
|
||||
return nil, fmt.Errorf("find_unused_functions: collecting used IDs: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Query all functions; filter out pipelines with "launcher" tag (intentional terminals).
|
||||
rows, err := db.Query(`
|
||||
SELECT id, name, lang, domain, tags, updated_at, kind
|
||||
FROM functions
|
||||
ORDER BY updated_at ASC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find_unused_functions: query functions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
now := time.Now().UTC()
|
||||
var result []UnusedFunction
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
id, name, lang, domain, tags, updatedAt, kind string
|
||||
)
|
||||
if err := rows.Scan(&id, &name, &lang, &domain, &tags, &updatedAt, &kind); err != nil {
|
||||
return nil, fmt.Errorf("find_unused_functions: scan: %w", err)
|
||||
}
|
||||
|
||||
// Skip if this function is used by someone.
|
||||
if _, used := usedIDs[id]; used {
|
||||
continue
|
||||
}
|
||||
|
||||
// Pipelines with "launcher" tag are intentional consumers — skip them.
|
||||
if kind == "pipeline" {
|
||||
var tagList []string
|
||||
_ = json.Unmarshal([]byte(tags), &tagList)
|
||||
for _, t := range tagList {
|
||||
if strings.TrimSpace(t) == "launcher" {
|
||||
goto next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
updatedTime, err := time.Parse(time.RFC3339, updatedAt)
|
||||
if err != nil {
|
||||
// Try without timezone suffix
|
||||
updatedTime, err = time.Parse("2006-01-02T15:04:05Z", updatedAt)
|
||||
if err != nil {
|
||||
updatedTime = now
|
||||
}
|
||||
}
|
||||
ageDays := int(now.Sub(updatedTime).Hours() / 24)
|
||||
result = append(result, UnusedFunction{
|
||||
ID: id,
|
||||
Name: name,
|
||||
Lang: lang,
|
||||
Domain: domain,
|
||||
Tags: tags,
|
||||
AgeDays: ageDays,
|
||||
})
|
||||
}
|
||||
|
||||
next:
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("find_unused_functions: rows: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
Reference in New Issue
Block a user