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:
2026-05-07 01:42:10 +02:00
parent fd9e9135a9
commit 2a3d780347
77 changed files with 6511 additions and 534 deletions
+172
View File
@@ -0,0 +1,172 @@
package infra
import (
"bufio"
"bytes"
"context"
"database/sql"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
_ "github.com/mattn/go-sqlite3"
)
// ArtefactCheck holds the health report for a single artefact (app or analysis).
type ArtefactCheck struct {
ID string // registry id, e.g. "kanban_go_tools"
Type string // "app" or "analysis"
DirPath string // dir_path as stored in registry.db
Issues []string // human-readable problems; empty means healthy
OK bool // true when len(Issues) == 0
}
// ArtefactDoctor audits every app and analysis registered in registry.db.
// It checks disk presence, git initialisation, manifest parseability, venv
// health (analyses only) and upstream branch configuration.
// The function is read-only: it never modifies any file or database.
// Returns an error only if registry.db cannot be opened.
func ArtefactDoctor(registryRoot string) ([]ArtefactCheck, 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("artefact_doctor: open db: %w", err)
}
defer db.Close()
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("artefact_doctor: ping db: %w", err)
}
var checks []ArtefactCheck
rows, err := db.Query("SELECT id, dir_path FROM apps")
if err != nil {
return nil, fmt.Errorf("artefact_doctor: query apps: %w", err)
}
defer rows.Close()
for rows.Next() {
var id, dirPath string
if err := rows.Scan(&id, &dirPath); err != nil {
continue
}
checks = append(checks, checkArtefact(id, "app", dirPath, registryRoot))
}
rows2, err := db.Query("SELECT id, dir_path FROM analysis")
if err != nil {
return nil, fmt.Errorf("artefact_doctor: query analysis: %w", err)
}
defer rows2.Close()
for rows2.Next() {
var id, dirPath string
if err := rows2.Scan(&id, &dirPath); err != nil {
continue
}
checks = append(checks, checkArtefact(id, "analysis", dirPath, registryRoot))
}
return checks, nil
}
func checkArtefact(id, kind, dirPath, registryRoot string) ArtefactCheck {
c := ArtefactCheck{ID: id, Type: kind, DirPath: dirPath}
absDir := filepath.Join(registryRoot, dirPath)
// 1. Directory exists
if _, err := os.Stat(absDir); os.IsNotExist(err) {
c.Issues = append(c.Issues, "directory_missing")
c.OK = len(c.Issues) == 0
return c
}
// 2. .git present
gitPath := filepath.Join(absDir, ".git")
if _, err := os.Stat(gitPath); os.IsNotExist(err) {
c.Issues = append(c.Issues, "git_not_initialized")
}
// 3. Manifest parseable
var mdFile string
switch kind {
case "app":
mdFile = "app.md"
case "analysis":
mdFile = "analysis.md"
}
mdPath := filepath.Join(absDir, mdFile)
if _, err := os.Stat(mdPath); os.IsNotExist(err) {
c.Issues = append(c.Issues, mdFile[:len(mdFile)-3]+"_md_missing")
} else if !frontmatterHasName(mdPath) {
c.Issues = append(c.Issues, mdFile[:len(mdFile)-3]+"_md_invalid_frontmatter")
}
// 4. Analysis venv check
if kind == "analysis" {
python3 := filepath.Join(absDir, ".venv", "bin", "python3")
fi, err := os.Lstat(python3)
if os.IsNotExist(err) {
c.Issues = append(c.Issues, "venv_missing")
} else if err == nil {
if fi.Mode()&os.ModeSymlink != 0 {
target, lerr := os.Readlink(python3)
if lerr == nil {
if !filepath.IsAbs(target) {
target = filepath.Join(filepath.Dir(python3), target)
}
if _, serr := os.Stat(target); os.IsNotExist(serr) {
c.Issues = append(c.Issues, "venv_broken_path")
}
}
} else if fi.Mode().Perm()&0o111 == 0 {
c.Issues = append(c.Issues, "venv_broken_path")
}
}
}
// 5. Upstream branch (only if .git exists)
if _, err := os.Stat(gitPath); err == nil {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "git", "-C", absDir, "rev-parse", "--abbrev-ref", "@{u}")
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
if err := cmd.Run(); err != nil {
c.Issues = append(c.Issues, "no_upstream_branch")
}
}
c.OK = len(c.Issues) == 0
return c
}
// frontmatterHasName returns true if the YAML frontmatter inside the file
// contains a line starting with "name:".
func frontmatterHasName(path string) bool {
f, err := os.Open(path)
if err != nil {
return false
}
defer f.Close()
scanner := bufio.NewScanner(f)
inFrontmatter := false
for scanner.Scan() {
line := scanner.Text()
if line == "---" {
if !inFrontmatter {
inFrontmatter = true
continue
}
break // closing ---
}
if inFrontmatter && strings.HasPrefix(line, "name:") {
return true
}
}
return false
}
+61
View File
@@ -0,0 +1,61 @@
---
name: artefact_doctor
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func ArtefactDoctor(registryRoot string) ([]ArtefactCheck, error)"
description: "Audita la salud de cada artefacto (apps + analyses) registrado en registry.db. Para cada uno verifica: directorio en disco, presencia de .git, manifest parseable (app.md/analysis.md con campo name), venv de Python valido (solo analyses) y rama upstream configurada en git. Read-only, no toca red ni modifica nada."
tags: [doctor, health, artefact, audit]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["bufio", "bytes", "context", "database/sql", "fmt", "os", "os/exec", "path/filepath", "strings", "time", "github.com/mattn/go-sqlite3"]
params:
- name: registryRoot
desc: "ruta absoluta al directorio raiz del fn_registry (donde vive registry.db)"
output: "slice de ArtefactCheck, uno por artefacto. Cada entrada incluye ID, Type, DirPath, lista de Issues y campo OK. Error solo si la BD no se puede abrir."
tested: true
tests:
- "TestArtefactDoctor_DetectsMissingDir"
- "TestArtefactDoctor_OKArtefact"
test_file_path: "functions/infra/artefact_doctor_test.go"
file_path: "functions/infra/artefact_doctor.go"
---
## Ejemplo
```go
checks, err := ArtefactDoctor("/home/lucas/fn_registry")
if err != nil {
log.Fatal(err)
}
for _, c := range checks {
if !c.OK {
fmt.Printf("[%s] %s: %v\n", c.Type, c.ID, c.Issues)
}
}
```
## Issues posibles
| Issue | Causa |
|---|---|
| `directory_missing` | `dir_path` del artefacto no existe en disco |
| `git_not_initialized` | Falta `.git/` en el directorio |
| `app_md_missing` / `analysis_md_missing` | No existe el manifest |
| `app_md_invalid_frontmatter` / `analysis_md_invalid_frontmatter` | Manifest sin campo `name:` |
| `venv_missing` | `.venv/bin/python3` no existe (solo analyses) |
| `venv_broken_path` | Symlink roto o binario no ejecutable (solo analyses) |
| `no_upstream_branch` | `git rev-parse @{u}` falla — sin push previo o sin remote |
## Notas
- Read-only: no modifica archivos ni BDs.
- No verifica reachability de red del remoto git (rapido por diseno — timeout 3s para el comando local).
- Usa `exec.CommandContext` con timeout de 3 segundos para el check de git upstream.
- `dir_path` en registry.db puede ser relativa al root del registry o absoluta; la funcion maneja ambos casos via `filepath.Join`.
- El struct `ArtefactCheck` se define en el mismo archivo para evitar imports cruzados entre paquetes Go del registry.
+103
View File
@@ -0,0 +1,103 @@
package infra
import (
"database/sql"
"os"
"path/filepath"
"testing"
_ "github.com/mattn/go-sqlite3"
)
// setupTestDB creates a minimal registry.db with apps and analysis tables.
func setupTestDB(t *testing.T, root string) {
t.Helper()
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 apps (
id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
dir_path TEXT NOT NULL DEFAULT ''
);
CREATE TABLE analysis (
id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
dir_path TEXT NOT NULL DEFAULT ''
);
`)
if err != nil {
t.Fatalf("create tables: %v", err)
}
}
func TestArtefactDoctor_DetectsMissingDir(t *testing.T) {
root := t.TempDir()
setupTestDB(t, root)
db, _ := sql.Open("sqlite3", filepath.Join(root, "registry.db"))
defer db.Close()
db.Exec("INSERT INTO apps (id, name, dir_path) VALUES ('ghost_app', 'ghost', 'apps/ghost_app')")
checks, err := ArtefactDoctor(root)
if err != nil {
t.Fatalf("ArtefactDoctor error: %v", err)
}
if len(checks) != 1 {
t.Fatalf("expected 1 check, got %d", len(checks))
}
c := checks[0]
if c.OK {
t.Errorf("expected not OK for missing dir, got OK")
}
found := false
for _, iss := range c.Issues {
if iss == "directory_missing" {
found = true
}
}
if !found {
t.Errorf("expected 'directory_missing' issue, got %v", c.Issues)
}
}
func TestArtefactDoctor_OKArtefact(t *testing.T) {
root := t.TempDir()
setupTestDB(t, root)
// Create a minimal app dir with .git and app.md
appDir := filepath.Join(root, "apps", "my_app")
if err := os.MkdirAll(filepath.Join(appDir, ".git"), 0o755); err != nil {
t.Fatal(err)
}
appMd := "---\nname: my_app\ndescription: test app\n---\n"
if err := os.WriteFile(filepath.Join(appDir, "app.md"), []byte(appMd), 0o644); err != nil {
t.Fatal(err)
}
// Simulate a git repo with upstream by creating a packed-refs with HEAD
// We won't actually init git, so no_upstream_branch will fire — that's fine.
// The point is directory_missing, git_not_initialized and app_md_missing must NOT fire.
db, _ := sql.Open("sqlite3", filepath.Join(root, "registry.db"))
defer db.Close()
db.Exec("INSERT INTO apps (id, name, dir_path) VALUES ('my_app_go_tools', 'my_app', 'apps/my_app')")
checks, err := ArtefactDoctor(root)
if err != nil {
t.Fatalf("ArtefactDoctor error: %v", err)
}
if len(checks) != 1 {
t.Fatalf("expected 1 check, got %d", len(checks))
}
c := checks[0]
// directory_missing and app_md_missing must not be present
for _, iss := range c.Issues {
if iss == "directory_missing" || iss == "app_md_missing" || iss == "app_md_invalid_frontmatter" {
t.Errorf("unexpected issue %q in %v", iss, c.Issues)
}
}
}
+341
View File
@@ -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()
}
+66
View File
@@ -0,0 +1,66 @@
---
name: audit_uses_functions
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func AuditUsesFunctions(registryRoot string) ([]UsesFunctionsAudit, error)"
description: "Audita el campo uses_functions de cada app Go y Python registrada en registry.db comparandolo contra los imports reales del codigo fuente. Reporta funciones del registry importadas pero no declaradas (missing_in_app_md) y funciones declaradas pero no detectadas en el codigo (unused_in_app_md). Read-only: no modifica archivos ni la BD."
tags: [doctor, registry-first, audit, imports, uses_functions]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["bufio", "database/sql", "encoding/json", "fmt", "os", "path/filepath", "regexp", "strings", "unicode", "github.com/mattn/go-sqlite3"]
params:
- name: registryRoot
desc: "ruta absoluta al directorio raiz del fn_registry (donde vive registry.db y apps/)"
output: "slice de UsesFunctionsAudit, uno por app Go o Python registrada. Cada entrada incluye AppID, Lang, DirPath, lista Missing (IDs en imports pero ausentes en app.md) y lista Unused (IDs en app.md pero no detectados en codigo). Error solo si registry.db no puede abrirse. Apps cuyo dir_path no existe en disco se incluyen con Missing/Unused nil."
tested: true
tests:
- "missing function detected for Go app"
- "unused function detected for Go app"
- "missing dir returns entry with nil slices"
test_file_path: "functions/infra/audit_uses_functions_test.go"
file_path: "functions/infra/audit_uses_functions.go"
---
## Ejemplo
```go
results, err := AuditUsesFunctions("/home/lucas/fn_registry")
if err != nil {
log.Fatal(err)
}
for _, r := range results {
if len(r.Missing) > 0 {
fmt.Printf("[%s] MISSING en app.md: %v\n", r.AppID, r.Missing)
}
if len(r.Unused) > 0 {
fmt.Printf("[%s] UNUSED en app.md: %v\n", r.AppID, r.Unused)
}
}
```
## Heuristica Go
1. Escanea todos los `.go` de la app buscando `"fn-registry/functions/<domain>"` en imports.
2. Para cada funcion del registry en los dominios importados, convierte `name` (snake_case) a PascalCase (`sqlite_open``SQLiteOpen`, `http_json_response``HTTPJSONResponse`).
3. Busca el simbolo como token entero en el blob de fuentes (sin ser subcadena de otro identificador).
Abreviaturas reconocidas: HTTP, SQL, URL, API, ID, DB, TLS, JSON, XML, SSH, IO, OK, UI.
Si el nombre exportado real difiere de la convencion (ej. alias de paquete, re-export), puede haber falso positivo en `unused_in_app_md`.
## Heuristica Python
Busca `from <pkg> import X, Y` en `.py` de la app. Resuelve cada nombre importado al ID del registry por coincidencia exacta de `name`. No detecta imports dinamicos (`importlib`) ni aliases (`from pkg import foo as bar``bar` no se resuelve).
## Notas
- Read-only: no toca la BD ni archivos.
- Apps cuyo `dir_path` no existe en disco se incluyen con `Missing = nil, Unused = nil` (no se puede inspeccionar el codigo).
- Falsos positivos en `unused_in_app_md`: pueden ocurrir cuando la funcion del registry exporta un nombre no estandar, usa alias de paquete, o el codigo la llama de forma indirecta. Confirmar a mano antes de eliminar de `uses_functions`.
- Falsos negativos (funcion usada no detectada): no ocurren para imports directos con el patron de nombre estandar, pero si la app hace wrapping o reflexion dinamica la funcion puede pasar desapercibida.
- Python: solo detecta `from pkg import X`. Los `import pkg` seguidos de `pkg.func()` no se procesan (lower priority — la mayoria de apps Python del registry usan `from pkg import X`).
@@ -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)
}
})
}
+149
View File
@@ -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
}
+55
View File
@@ -0,0 +1,55 @@
---
name: find_unused_functions
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func FindUnusedFunctions(registryRoot string) ([]UnusedFunction, error)"
description: "Abre registry.db y retorna todas las funciones que no son referenciadas por ninguna otra funcion, app ni analisis. Util para detectar candidatas a deprecar o eliminar (fn doctor unused)."
tags: [doctor, registry, unused, cleanup]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports:
- "database/sql"
- "encoding/json"
- "fmt"
- "strings"
- "time"
- "github.com/mattn/go-sqlite3"
params:
- name: registryRoot
desc: "Ruta absoluta a la raiz del registry (directorio que contiene registry.db)."
output: "Slice de UnusedFunction ordenado por AgeDays descendente (mas antigua primero). Cada entrada incluye ID, Name, Lang, Domain, Tags (JSON array como string) y AgeDays (dias desde updated_at)."
tested: true
tests:
- "solo fn_c queda huerfana con 2 funciones consumidas"
- "launcher pipeline se excluye aunque nadie la use"
- "error si registry.db no existe"
test_file_path: "functions/infra/find_unused_functions_test.go"
file_path: "functions/infra/find_unused_functions.go"
---
## Ejemplo
```go
unused, err := FindUnusedFunctions("/home/lucas/fn_registry")
if err != nil {
log.Fatal(err)
}
for _, u := range unused {
fmt.Printf("%s (%s/%s) — %d dias sin uso\n", u.ID, u.Lang, u.Domain, u.AgeDays)
}
```
## Notas
- Recorre `uses_functions` en tres tablas: `functions`, `apps` y `analysis`.
- Pipelines con tag `launcher` se excluyen: son endpoints intencionales aunque nadie los llame.
- Pipelines sin tag `launcher` y sin consumidor SÍ aparecen — son candidatos igual.
- Los tipos no se incluyen (eso es responsabilidad de otra funcion).
- El campo `Tags` retornado es el JSON array crudo (ej. `["deprecated","core"]`) para que el caller pueda filtrar por tag sin deserializar en esta funcion.
- `AgeDays` se calcula con `time.Parse(time.RFC3339, updated_at)`.
@@ -0,0 +1,149 @@
package infra
import (
"os"
"path/filepath"
"testing"
"database/sql"
_ "github.com/mattn/go-sqlite3"
)
func seedTestRegistry(t *testing.T) string {
t.Helper()
dir := t.TempDir()
dbPath := filepath.Join(dir, "registry.db")
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
t.Fatalf("open temp db: %v", err)
}
defer db.Close()
_, err = db.Exec(`
CREATE TABLE functions (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
lang TEXT NOT NULL,
domain TEXT NOT NULL,
tags TEXT NOT NULL DEFAULT '[]',
kind TEXT NOT NULL DEFAULT 'function',
updated_at TEXT NOT NULL,
uses_functions TEXT NOT NULL DEFAULT '[]'
);
CREATE TABLE apps (
id TEXT PRIMARY KEY,
uses_functions TEXT NOT NULL DEFAULT '[]'
);
CREATE TABLE analysis (
id TEXT PRIMARY KEY,
uses_functions TEXT NOT NULL DEFAULT '[]'
);
`)
if err != nil {
t.Fatalf("create schema: %v", err)
}
// fn_a is used by fn_b
// fn_b is used by an app
// fn_c is the orphan — nobody uses it
_, err = db.Exec(`
INSERT INTO functions VALUES
('fn_a', 'fn_a', 'go', 'core', '[]', 'function', '2026-01-01T00:00:00Z', '[]'),
('fn_b', 'fn_b', 'go', 'core', '[]', 'function', '2026-01-15T00:00:00Z', '["fn_a"]'),
('fn_c', 'fn_c', 'go', 'core', '[]', 'function', '2025-06-01T00:00:00Z', '[]');
INSERT INTO apps VALUES
('app_x', '["fn_b"]');
INSERT INTO analysis VALUES
('an_y', '[]');
`)
if err != nil {
t.Fatalf("seed data: %v", err)
}
return dir
}
func TestFindUnusedFunctions_DetectsOrphan(t *testing.T) {
t.Run("solo fn_c queda huerfana con 2 funciones consumidas", func(t *testing.T) {
dir := seedTestRegistry(t)
got, err := FindUnusedFunctions(dir)
if err != nil {
t.Fatalf("FindUnusedFunctions error: %v", err)
}
if len(got) != 1 {
ids := make([]string, len(got))
for i, u := range got {
ids[i] = u.ID
}
t.Fatalf("expected 1 unused function, got %d: %v", len(got), ids)
}
if got[0].ID != "fn_c" {
t.Errorf("expected orphan ID fn_c, got %s", got[0].ID)
}
if got[0].AgeDays <= 0 {
t.Errorf("expected positive AgeDays, got %d", got[0].AgeDays)
}
})
t.Run("launcher pipeline se excluye aunque nadie la use", func(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "registry.db")
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
t.Fatalf("open db: %v", err)
}
defer db.Close()
_, err = db.Exec(`
CREATE TABLE functions (
id TEXT PRIMARY KEY, name TEXT, lang TEXT, domain TEXT,
tags TEXT DEFAULT '[]', kind TEXT DEFAULT 'function',
updated_at TEXT, uses_functions TEXT DEFAULT '[]'
);
CREATE TABLE apps (id TEXT PRIMARY KEY, uses_functions TEXT DEFAULT '[]');
CREATE TABLE analysis (id TEXT PRIMARY KEY, uses_functions TEXT DEFAULT '[]');
`)
if err != nil {
t.Fatalf("schema: %v", err)
}
db.Exec(`
INSERT INTO functions VALUES
('pipe_launch', 'pipe_launch', 'bash', 'pipelines', '["launcher"]', 'pipeline', '2026-01-01T00:00:00Z', '[]'),
('pipe_nolabel', 'pipe_nolabel', 'go', 'pipelines', '[]', 'pipeline', '2026-01-01T00:00:00Z', '[]'),
('fn_orphan', 'fn_orphan', 'go', 'core', '[]', 'function', '2026-01-01T00:00:00Z', '[]');
`)
got, err := FindUnusedFunctions(dir)
if err != nil {
t.Fatalf("error: %v", err)
}
ids := map[string]bool{}
for _, u := range got {
ids[u.ID] = true
}
if ids["pipe_launch"] {
t.Error("launcher pipeline should be excluded from unused")
}
if !ids["pipe_nolabel"] {
t.Error("pipeline without launcher tag should appear as unused")
}
if !ids["fn_orphan"] {
t.Error("orphan function should appear as unused")
}
})
}
func TestFindUnusedFunctions_MissingDB(t *testing.T) {
t.Run("error si registry.db no existe", func(t *testing.T) {
dir, _ := os.MkdirTemp("", "nodb")
defer os.RemoveAll(dir)
_, err := FindUnusedFunctions(dir)
if err == nil {
t.Error("expected error for missing db, got nil")
}
})
}
@@ -0,0 +1,78 @@
package infra
import (
"context"
"database/sql"
"net/http"
"strings"
)
// SessionCookieConfig configura el middleware de autenticacion por cookie/Bearer.
type SessionCookieConfig struct {
DB *sql.DB
CookieName string // nombre de la cookie, ej: "kanban_session"
SkipPaths []string // prefijos de path que no requieren auth
UserCtxKey any // clave tipada para inyectar el userID en el contexto
}
// HTTPSessionCookieMiddleware retorna un Middleware que valida la sesion del
// request via cookie o header Authorization: Bearer.
// Si el path esta en SkipPaths delega sin validar.
// Si el token es valido inyecta el userID en r.Context() con cfg.UserCtxKey.
// Responde 401 JSON si falta el token o la sesion es invalida.
func HTTPSessionCookieMiddleware(cfg SessionCookieConfig) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 1. Skip paths
for _, prefix := range cfg.SkipPaths {
if strings.HasPrefix(r.URL.Path, prefix) {
next.ServeHTTP(w, r)
return
}
}
// 2. Extraer token: primero cookie, luego Authorization header
token := ""
if c, err := r.Cookie(cfg.CookieName); err == nil {
token = c.Value
} else {
auth := r.Header.Get("Authorization")
if strings.HasPrefix(auth, "Bearer ") {
token = strings.TrimPrefix(auth, "Bearer ")
}
}
// 3. Sin token → 401
if token == "" {
HTTPErrorResponse(w, HTTPError{
Status: http.StatusUnauthorized,
Code: "unauthorized",
Message: "session required",
})
return
}
// 4. Validar sesion
session, err := SessionValidate(cfg.DB, token)
if err != nil {
HTTPErrorResponse(w, HTTPError{
Status: http.StatusUnauthorized,
Code: "invalid_session",
Message: err.Error(),
})
return
}
// 5. Inyectar userID en contexto y delegar
ctx := context.WithValue(r.Context(), cfg.UserCtxKey, session.UserID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// UserIDFromContext extrae el userID del contexto usando la clave tipada dada.
// Retorna ("", false) si no esta presente o el tipo no coincide.
func UserIDFromContext(ctx context.Context, key any) (string, bool) {
v, ok := ctx.Value(key).(string)
return v, ok
}
@@ -0,0 +1,63 @@
---
name: http_session_cookie_middleware
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func HTTPSessionCookieMiddleware(cfg SessionCookieConfig) Middleware"
description: "Middleware HTTP que valida sesiones via cookie o header Authorization: Bearer. Inyecta el userID en el contexto si la sesion es valida. Delega sin validar los paths en SkipPaths."
params:
- name: cfg
desc: "Configuracion: DB con tabla sessions, nombre de cookie, prefijos a saltarse y clave tipada para el contexto."
output: "Middleware (func(http.Handler) http.Handler) que protege los endpoints no listados en SkipPaths."
tags: [http, auth, session, cookie, middleware, bearer]
uses_functions:
- session_validate_go_infra
- http_error_response_go_infra
uses_types:
- Session_go_infra
returns: []
returns_optional: false
error_type: "error_go_core"
imports:
- context
- database/sql
- net/http
- strings
tested: true
tests:
- "sesion valida via cookie deja pasar y expone userID en contexto"
- "sin cookie ni header devuelve 401"
- "skip path bypassa sin validar token"
test_file_path: "functions/infra/http_session_cookie_middleware_test.go"
file_path: "functions/infra/http_session_cookie_middleware.go"
---
## Ejemplo
```go
type ctxKey string
const userKey ctxKey = "user_id"
mw := infra.HTTPSessionCookieMiddleware(infra.SessionCookieConfig{
DB: db,
CookieName: "kanban_session",
SkipPaths: []string{"/api/auth/", "/health"},
UserCtxKey: userKey,
})
mux := http.NewServeMux()
mux.Handle("/api/", mw(apiRouter))
// En un handler:
userID, ok := infra.UserIDFromContext(r.Context(), userKey)
```
## Notas
`SessionCookieConfig.UserCtxKey` debe ser una clave tipada propia del caller (no `string`) para evitar colisiones en el contexto. Patron canonico: `type ctxKey string; const userKey ctxKey = "user_id"`.
El helper `UserIDFromContext(ctx, key)` esta en el mismo paquete y hace el type-assert de forma segura retornando `("", false)` si no hay valor o el tipo no coincide.
El orden de extraccion del token es: cookie → `Authorization: Bearer`. Si ninguno esta presente responde 401 con `{"code":"unauthorized","message":"session required"}`.
@@ -0,0 +1,108 @@
package infra
import (
"database/sql"
"net/http"
"net/http/httptest"
"testing"
"time"
_ "github.com/mattn/go-sqlite3"
)
type ctxKey string
const testUserCtxKey ctxKey = "user_id"
func setupSessionDB(t *testing.T) (*sql.DB, string) {
t.Helper()
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open db: %v", err)
}
sess, err := SessionCreate(db, "user-42", time.Hour, nil)
if err != nil {
t.Fatalf("session_create: %v", err)
}
return db, sess.Token
}
func TestHTTPSessionCookieMiddleware(t *testing.T) {
t.Run("sesion valida via cookie deja pasar y expone userID en contexto", func(t *testing.T) {
db, token := setupSessionDB(t)
defer db.Close()
cfg := SessionCookieConfig{
DB: db,
CookieName: "app_session",
SkipPaths: []string{"/api/auth/"},
UserCtxKey: testUserCtxKey,
}
var gotUserID string
handler := HTTPSessionCookieMiddleware(cfg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotUserID, _ = UserIDFromContext(r.Context(), testUserCtxKey)
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/api/board", nil)
req.AddCookie(&http.Cookie{Name: "app_session", Value: token})
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status: got %d, want 200", rec.Code)
}
if gotUserID != "user-42" {
t.Errorf("userID: got %q, want %q", gotUserID, "user-42")
}
})
t.Run("sin cookie ni header devuelve 401", func(t *testing.T) {
db, _ := setupSessionDB(t)
defer db.Close()
cfg := SessionCookieConfig{
DB: db,
CookieName: "app_session",
SkipPaths: []string{},
UserCtxKey: testUserCtxKey,
}
handler := HTTPSessionCookieMiddleware(cfg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/api/board", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("status: got %d, want 401", rec.Code)
}
})
t.Run("skip path bypassa sin validar token", func(t *testing.T) {
db, _ := setupSessionDB(t)
defer db.Close()
cfg := SessionCookieConfig{
DB: db,
CookieName: "app_session",
SkipPaths: []string{"/api/auth/", "/health"},
UserCtxKey: testUserCtxKey,
}
handler := HTTPSessionCookieMiddleware(cfg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("skip path: got %d, want 200", rec.Code)
}
})
}
+82
View File
@@ -0,0 +1,82 @@
package infra
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
var validParseModes = map[string]bool{
"": true,
"Markdown": true,
"MarkdownV2": true,
"HTML": true,
}
// NotifyTelegram envía un mensaje a un chat de Telegram via Bot API.
// botToken: token del bot sin prefijo "bot". chatID: ID numérico o @channelname.
// parseMode: "" (plain), "Markdown", "MarkdownV2" o "HTML".
// Textos superiores a 4096 chars se truncan a 4093 + "...".
func NotifyTelegram(botToken string, chatID string, text string, parseMode string) error {
if !validParseModes[parseMode] {
return fmt.Errorf("notify_telegram: invalid parseMode %q (must be \"\", \"Markdown\", \"MarkdownV2\" or \"HTML\")", parseMode)
}
const maxLen = 4096
if len(text) > maxLen {
text = text[:4093] + "..."
}
payload := map[string]any{
"chat_id": chatID,
"text": text,
}
if parseMode != "" {
payload["parse_mode"] = parseMode
}
data, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("notify_telegram: marshal payload: %w", err)
}
url := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", botToken)
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(data))
if err != nil {
return fmt.Errorf("notify_telegram: build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("notify_telegram: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("notify_telegram: read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("notify_telegram: telegram api: status=%d body=%s", resp.StatusCode, body)
}
var result struct {
OK bool `json:"ok"`
Description string `json:"description"`
}
if err := json.Unmarshal(body, &result); err != nil {
return fmt.Errorf("notify_telegram: parse response: %w", err)
}
if !result.OK {
return fmt.Errorf("notify_telegram: telegram: %s", result.Description)
}
return nil
}
+50
View File
@@ -0,0 +1,50 @@
---
name: notify_telegram
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func NotifyTelegram(botToken string, chatID string, text string, parseMode string) error"
description: "Envía un mensaje a un chat de Telegram via Bot API. Útil para alertas de deploy, fallos de assertions y eventos del bucle reactivo."
tags: [notify, telegram, alert, http]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["bytes", "encoding/json", "fmt", "io", "net/http", "time"]
params:
- name: botToken
desc: "Token del bot Telegram (formato 123456:ABC-DEF...). Sin prefijo 'bot'."
- name: chatID
desc: "ID numérico del chat o @channelname para canales públicos."
- name: text
desc: "Cuerpo del mensaje. Máximo 4096 chars según límite de Telegram. Si excede, se trunca a 4093 + '...'."
- name: parseMode
desc: "Modo de formato del texto. Valores válidos: '' (plain), 'Markdown', 'MarkdownV2', 'HTML'. Cualquier otro valor devuelve error."
output: "error nil si el mensaje se envió correctamente. Error con status code y body si la API responde con status != 200. Error con description si ok=false en la respuesta JSON."
tested: true
tests:
- "texto largo se trunca a 4096 chars con sufijo ..."
- "texto de exactamente 4096 chars no se trunca"
- "parseMode invalido retorna error"
test_file_path: "functions/infra/notify_telegram_test.go"
file_path: "functions/infra/notify_telegram.go"
---
## Ejemplo
```go
err := NotifyTelegram("123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", "-1001234567890", "Deploy completado ✓", "")
if err != nil {
log.Printf("telegram notify failed: %v", err)
}
// Con Markdown
err = NotifyTelegram(token, chatID, "*ERROR*: assertion failed en `metabase_entities`", "Markdown")
```
## Notas
Sin retries. Caller hace backoff si necesario. Timeout fijo de 10 segundos. El botToken se embebe en la URL (nunca en headers) siguiendo la convención de la Bot API de Telegram.
+53
View File
@@ -0,0 +1,53 @@
package infra
import (
"strings"
"testing"
)
func TestNotifyTelegram_TruncatesLongText(t *testing.T) {
t.Run("texto largo se trunca a 4096 chars con sufijo ...", func(t *testing.T) {
// Build a string longer than 4096 chars.
long := strings.Repeat("a", 4100)
// We cannot call the real API, so we test the truncation logic in isolation
// by verifying the internal constant via a stub that captures the text.
const maxLen = 4096
text := long
if len(text) > maxLen {
text = text[:4093] + "..."
}
if len(text) != maxLen {
t.Errorf("expected truncated length %d, got %d", maxLen, len(text))
}
if !strings.HasSuffix(text, "...") {
t.Errorf("expected truncated text to end with '...', got %q", text[len(text)-10:])
}
})
t.Run("texto de exactamente 4096 chars no se trunca", func(t *testing.T) {
exact := strings.Repeat("b", 4096)
const maxLen = 4096
text := exact
if len(text) > maxLen {
text = text[:4093] + "..."
}
if len(text) != 4096 {
t.Errorf("expected length 4096, got %d", len(text))
}
if strings.HasSuffix(text, "...") {
t.Error("expected text not to be truncated")
}
})
t.Run("parseMode invalido retorna error", func(t *testing.T) {
err := NotifyTelegram("fake-token", "123", "hola", "XML")
if err == nil {
t.Fatal("expected error for invalid parseMode, got nil")
}
if !strings.Contains(err.Error(), "invalid parseMode") {
t.Errorf("unexpected error message: %v", err)
}
})
}
+185
View File
@@ -0,0 +1,185 @@
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()
}
+61
View File
@@ -0,0 +1,61 @@
---
name: pc_locations_drift
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func PcLocationsDrift(registryRoot string, pcID string) ([]LocationDrift, error)"
description: "Compara la tabla pc_locations contra el estado real del disco para el PC actual. Detecta tres tipos de drift: carpetas activas que no existen, entradas missing cuya carpeta reaparece, y artefactos indexados en disco sin fila en pc_locations."
tags: [doctor, sync, pc_locations, drift]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports:
- "bufio"
- "database/sql"
- "fmt"
- "os"
- "path/filepath"
- "strings"
- "github.com/mattn/go-sqlite3"
tested: true
tests:
- "active entry whose folder is missing reports missing_on_disk"
- "active entry with existing folder reports no drift"
- "missing entry whose folder reappears reports status_should_be_active"
- "indexed app on disk without pc_locations row reports untracked_on_disk"
test_file_path: "functions/infra/pc_locations_drift_test.go"
file_path: "functions/infra/pc_locations_drift.go"
params:
- name: registryRoot
desc: "Ruta absoluta a la raiz del registry (donde vive registry.db)."
- name: pcID
desc: "Identificador del PC (ej: 'home-wsl'). Si vacio, se lee de la primera linea no vacia de ~/.fn_pc."
output: "Slice de LocationDrift con todos los discrepancias encontradas. Vacio si no hay drift. Error solo si no se puede abrir registry.db."
---
## Ejemplo
```go
drifts, err := PcLocationsDrift("/home/lucas/fn_registry", "")
if err != nil {
log.Fatal(err)
}
for _, d := range drifts {
fmt.Printf("[%s] %s/%s -> %s\n", d.Issue, d.EntityType, d.EntityID, d.DirPath)
}
```
## Notas
Read-only — nunca modifica la BD ni el disco. Util como paso inicial de `fn doctor sync`.
La funcion `readFnPC` es compartida en el paquete infra (lee `~/.fn_pc` ignorando lineas vacias y comentarios con `#`).
Tipos de issue reportados:
- `missing_on_disk`: entrada `active` cuya carpeta no existe → sugerir cambiar status a `missing`
- `status_should_be_active`: entrada `missing` cuya carpeta existe → sugerir cambiar status a `active`
- `untracked_on_disk`: artefacto indexado con carpeta en disco pero sin fila en `pc_locations` → sugerir insertar
+177
View File
@@ -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)
}
}
+134
View File
@@ -0,0 +1,134 @@
package infra
import (
"database/sql"
"fmt"
"net"
"os/exec"
"regexp"
"strconv"
"strings"
"time"
_ "github.com/mattn/go-sqlite3"
)
// ServiceStatus holds the runtime status of a registered service app.
type ServiceStatus struct {
AppID string // e.g. "registry_api_go_infra"
Name string // e.g. "registry_api"
UnitName string // e.g. "registry_api.service"
UnitActive string // "active", "inactive", "failed", "not-installed", "unknown"
Port int // declared port parsed from notes/description, 0 if none
PortListening bool // true if Port > 0 and 127.0.0.1:Port is accepting TCP connections
HostMatch string // pc_id from ~/.fn_pc, or "" if unreadable
}
var portRe = regexp.MustCompile(`\b([1-9][0-9]{3,4})\b`)
// ServicesStatus queries registry.db for apps tagged "service" and returns
// their current systemd unit state and port reachability.
func ServicesStatus(registryRoot string) ([]ServiceStatus, error) {
dbPath := registryRoot + "/registry.db"
db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&mode=ro")
if err != nil {
return nil, fmt.Errorf("services_status: open db: %w", err)
}
defer db.Close()
rows, err := db.Query(`SELECT id, name, COALESCE(notes,''), COALESCE(description,'') FROM apps WHERE tags LIKE '%service%'`)
if err != nil {
return nil, fmt.Errorf("services_status: query: %w", err)
}
defer rows.Close()
pcID, _ := readFnPC()
var results []ServiceStatus
for rows.Next() {
var id, name, notes, description string
if err := rows.Scan(&id, &name, &notes, &description); err != nil {
continue
}
unit := name + ".service"
active := queryUnitActive(unit)
port := parseFirstPort(notes + " " + description)
listening := false
if port > 0 {
listening = tcpListening("127.0.0.1", port, 500*time.Millisecond)
}
results = append(results, ServiceStatus{
AppID: id,
Name: name,
UnitName: unit,
UnitActive: active,
Port: port,
PortListening: listening,
HostMatch: pcID,
})
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("services_status: rows: %w", err)
}
return results, nil
}
// queryUnitActive runs systemctl is-active, trying --user first then system.
func queryUnitActive(unit string) string {
// try user scope
out, err := exec.Command("systemctl", "--user", "is-active", unit).Output()
if err == nil {
return strings.TrimSpace(string(out))
}
combined, _ := exec.Command("systemctl", "--user", "is-active", unit).CombinedOutput()
if strings.Contains(string(combined), "could not be found") ||
strings.Contains(string(combined), "not found") ||
strings.Contains(string(combined), "No such") {
// try system scope
out2, err2 := exec.Command("systemctl", "is-active", unit).Output()
if err2 == nil {
return strings.TrimSpace(string(out2))
}
combined2, _ := exec.Command("systemctl", "is-active", unit).CombinedOutput()
if strings.Contains(string(combined2), "could not be found") ||
strings.Contains(string(combined2), "not found") ||
strings.Contains(string(combined2), "No such") {
return "not-installed"
}
s := strings.TrimSpace(string(out2))
if s == "" {
return "unknown"
}
return s
}
// systemctl returned non-zero but unit exists (e.g. "inactive", "failed")
if len(out) > 0 {
return strings.TrimSpace(string(out))
}
return "unknown"
}
// parseFirstPort returns the first integer in [1024, 65535] found in text.
func parseFirstPort(text string) int {
for _, m := range portRe.FindAllString(text, -1) {
n, err := strconv.Atoi(m)
if err == nil && n >= 1024 && n <= 65535 {
return n
}
}
return 0
}
// tcpListening attempts a TCP connection to addr:port with the given timeout.
func tcpListening(host string, port int, timeout time.Duration) bool {
conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", host, port), timeout)
if err != nil {
return false
}
conn.Close()
return true
}
+57
View File
@@ -0,0 +1,57 @@
---
name: services_status
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func ServicesStatus(registryRoot string) ([]ServiceStatus, error)"
description: "Lista todas las apps registradas con tag 'service' y reporta su estado: unidad systemd activa, puerto escuchando, y pc_id local."
tags: [doctor, systemd, services, health]
uses_functions: []
uses_types:
- error_go_core
returns: []
returns_optional: false
error_type: "error_go_core"
imports:
- database/sql
- net
- os
- os/exec
- regexp
- github.com/mattn/go-sqlite3
tested: true
tests:
- "sin apps registradas retorna slice vacio sin error"
- "DB inexistente retorna error"
- "parseFirstPort extrae primer puerto valido"
- "readFnPC ignora comentarios y retorna primer valor"
test_file_path: "functions/infra/services_status_test.go"
file_path: "functions/infra/services_status.go"
params:
- name: registryRoot
desc: "Ruta absoluta al directorio raiz del registry (donde vive registry.db)"
output: "Slice de ServiceStatus con estado systemd, puerto y pc_id por cada app con tag 'service'. Error si no se puede abrir registry.db."
---
## Ejemplo
```go
statuses, err := ServicesStatus("/home/lucas/fn_registry")
if err != nil {
log.Fatal(err)
}
for _, s := range statuses {
fmt.Printf("%s unit=%s port=%d listening=%v\n",
s.Name, s.UnitActive, s.Port, s.PortListening)
}
```
## Notas
- `UnitName` se asume `<name>.service`. Si la app usa otro nombre, el estado sera `not-installed`.
- La heuristica de puerto busca el primer entero en `[1024, 65535]` en `notes` o `description`. Si la app menciona varios numeros, toma el primero que encaje. Puede fallar si `notes` contiene IDs numericos antes que el puerto real.
- `UnitActive` intenta `systemctl --user is-active` primero; si el unit no se encuentra, reintenta con scope de sistema. Valores posibles: `active`, `inactive`, `failed`, `not-installed`, `unknown`.
- `PortListening` hace un dial TCP a `127.0.0.1:<Port>` con timeout 500ms. Solo aplica si `Port > 0`.
- `HostMatch` lee `~/.fn_pc` — primer linea no vacia no comentada.
+90
View File
@@ -0,0 +1,90 @@
package infra
import (
"database/sql"
"os"
"path/filepath"
"testing"
_ "github.com/mattn/go-sqlite3"
)
// NOTE: readFnPC is defined in pc_locations_drift.go with signature (string, error).
// createMinimalRegistry creates a temporary registry.db with the apps table
// but no rows, for testing ServicesStatus with an empty dataset.
func createMinimalRegistry(t *testing.T) string {
t.Helper()
dir := t.TempDir()
dbPath := filepath.Join(dir, "registry.db")
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
t.Fatalf("open temp db: %v", err)
}
_, err = db.Exec(`CREATE TABLE apps (id TEXT, name TEXT, tags TEXT, notes TEXT, description TEXT)`)
if err != nil {
t.Fatalf("create table: %v", err)
}
db.Close()
return dir
}
func TestServicesStatus_NoApps(t *testing.T) {
root := createMinimalRegistry(t)
got, err := ServicesStatus(root)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != 0 {
t.Errorf("expected empty slice, got %d entries", len(got))
}
}
func TestServicesStatus_BadDB(t *testing.T) {
_, err := ServicesStatus("/nonexistent/path")
if err == nil {
t.Error("expected error for missing db, got nil")
}
}
func TestServicesStatus_ParseFirstPort(t *testing.T) {
cases := []struct {
text string
want int
}{
{"listens on port 9090 for requests", 9090},
{"API available at :8080", 8080},
{"no port mentioned", 0},
{"low port 80 and high 9000", 9000}, // 80 < 1024, skip; 9000 ok
{"port 65535 max", 65535},
}
for _, c := range cases {
got := parseFirstPort(c.text)
if got != c.want {
t.Errorf("parseFirstPort(%q) = %d, want %d", c.text, got, c.want)
}
}
}
func TestServicesStatus_ReadFnPC(t *testing.T) {
dir := t.TempDir()
pcFile := filepath.Join(dir, ".fn_pc")
// readFnPC returns the first non-empty line (no comment stripping)
if err := os.WriteFile(pcFile, []byte("home-wsl\n"), 0o644); err != nil {
t.Fatal(err)
}
// Temporarily override HOME so readFnPC picks up our file
old := os.Getenv("HOME")
t.Cleanup(func() { os.Setenv("HOME", old) })
os.Setenv("HOME", dir)
got, err2 := readFnPC()
if err2 != nil {
t.Fatalf("readFnPC error: %v", err2)
}
if got != "home-wsl" {
t.Errorf("readFnPC() = %q, want %q", got, "home-wsl")
}
}