diff --git a/bash/functions/infra/audit_doctor_snapshot.md b/bash/functions/infra/audit_doctor_snapshot.md new file mode 100644 index 00000000..0f34d492 --- /dev/null +++ b/bash/functions/infra/audit_doctor_snapshot.md @@ -0,0 +1,70 @@ +--- +name: audit_doctor_snapshot +kind: function +lang: bash +domain: infra +version: "1.0.0" +purity: impure +signature: "audit_doctor_snapshot(doctor_subcommand: string, snapshot_base_dir: string) -> void" +description: "Ejecuta un subcomando de fn doctor --json, guarda un snapshot JSON fechado en //.json, lo compara con la corrida anterior (latest.json) y emite a stdout un resumen legible: count actual, count previo, IDs nuevos y resueltos. Pieza de observabilidad Nivel 1 para DAGs de auditoría periódica." +tags: [audit, registry, infra, doctor, snapshot, diff, dag] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +params: + - name: doctor_subcommand + desc: "Subcomando de fn doctor a ejecutar (unused, capabilities, artefacts, copied-code, uses-functions, cpp-apps, services, sync, etc.)." + - name: snapshot_base_dir + desc: "Directorio base donde se crea la carpeta // con los snapshots fechados y latest.json." +output: "Resumen a stdout: '[audit:] count=N prev=M +X new -Y resolved'. Si hay IDs nuevos/resueltos, líneas adicionales NEW:/RESOLVED: con hasta 8 IDs. Snapshots JSON en disco." +tested: false +tests: [] +test_file_path: "" +file_path: "bash/functions/infra/audit_doctor_snapshot.sh" +--- + +## Ejemplo + +```bash +# Primera corrida — establece baseline +FN_REGISTRY_ROOT=/home/enmanuel/fn_registry \ +FN_BIN=/home/enmanuel/fn_registry/fn \ +bash bash/functions/infra/audit_doctor_snapshot.sh \ + unused \ + /home/enmanuel/fn_registry/apps/dag_engine/local_files/audits/daily +# => [audit:unused] count=12 prev=- baseline (sin corrida previa) + +# Segunda corrida — compara contra latest.json +FN_REGISTRY_ROOT=/home/enmanuel/fn_registry \ +FN_BIN=/home/enmanuel/fn_registry/fn \ +bash bash/functions/infra/audit_doctor_snapshot.sh \ + unused \ + /home/enmanuel/fn_registry/apps/dag_engine/local_files/audits/daily +# => [audit:unused] count=12 prev=12 +0 new -0 resolved + +# Con otro subcomando (directorio independiente automático) +audit_doctor_snapshot artefacts /tmp/audits/weekly +``` + +## Cuando usarla + +Úsala en un DAG/cron que ejecuta `fn doctor` periódicamente y quieres **persistir el resultado y ver qué cambió desde la última corrida**: funciones huérfanas que aparecieron, artefactos rotos nuevos, capabilities sin doc, etc. Es la pieza "snapshot + diff" del Nivel 1 de observabilidad de auditorías — el DAG llama esta función en vez de descartar el output de `fn doctor`. + +## Gotchas + +- **Depende de `FN_BIN` o `FN_REGISTRY_ROOT`** en el entorno. Si ninguno está seteado, asume `$HOME/fn_registry/fn`. En DAGs, asegúrate de exportar `FN_REGISTRY_ROOT` antes de invocar. +- **`latest.json` se sobreescribe cada corrida** — es el snapshot de referencia para el diff siguiente. No es un historial acumulado; el historial está en los archivos fechados `.json`. +- **Si cambias de subcomando, el subdirectorio es distinto** (`/unused/` vs `/artefacts/`), así que no hay contaminación entre subcomandos aunque compartan el mismo `base_dir`. +- **Si `fn doctor ` falla (rc != 0)**, la función propaga ese exit code. Esto es intencional: doctor roto = problema real que el DAG debe reportar. Los hallazgos normales (funciones huérfanas, artefactos con drift) tienen rc=0 en `fn doctor`. +- **jq es dependencia requerida**. Está disponible en el ecosistema del registry pero si el entorno no lo tiene, los conteos y diffs de IDs caen a `?`/textual respectivamente. +- **Retención automática**: snapshots fechados con más de 30 días se borran con `find -mtime +30`. `latest.json` nunca se borra. +- **Estructura del JSON de `fn doctor`**: el diff de IDs busca campos `.ID` o `.id` en los elementos. Si el subcomando produce una estructura distinta (objeto anidado sin esos campos), el diff cae a comparación textual, que sigue siendo útil. + +## Notas + +Diseñada para ser invocada desde steps del dag_engine (`daily-registry-audit`, `weekly-deep-scan`) como reemplazo del descarte silencioso del output de `fn doctor --json`. La salida stdout es legible por humanos y parseable por el orquestador del DAG para decidir si crear proposals. + +Binario `fn` resuelto en orden: `$FN_BIN` → `${FN_REGISTRY_ROOT}/fn` → `$HOME/fn_registry/fn`. diff --git a/bash/functions/infra/audit_doctor_snapshot.sh b/bash/functions/infra/audit_doctor_snapshot.sh new file mode 100644 index 00000000..1bf93628 --- /dev/null +++ b/bash/functions/infra/audit_doctor_snapshot.sh @@ -0,0 +1,169 @@ +#!/usr/bin/env bash +# audit_doctor_snapshot — ejecuta un subcomando de fn doctor, guarda snapshot JSON +# fechado, compara con la corrida anterior y emite resumen legible de cambios. +# +# Uso: audit_doctor_snapshot +# +# Ejemplo: +# audit_doctor_snapshot unused /home/enmanuel/fn_registry/apps/dag_engine/local_files/audits/daily + +set -uo pipefail + +audit_doctor_snapshot() { + local sub="${1:-}" + local base="${2:-}" + + # --- validacion de argumentos --- + if [[ -z "$sub" || -z "$base" ]]; then + echo "usage: audit_doctor_snapshot " >&2 + return 2 + fi + + # --- resolver binario fn --- + local fn_bin="${FN_BIN:-${FN_REGISTRY_ROOT:-$HOME/fn_registry}/fn}" + if [[ ! -x "$fn_bin" ]]; then + echo "audit_doctor_snapshot: binario fn no encontrado o no ejecutable: $fn_bin" >&2 + return 2 + fi + + # --- preparar directorio --- + local dir="$base/$sub" + mkdir -p "$dir" + + # --- ejecutar fn doctor --- + local stderr_tmp + stderr_tmp="$(mktemp /tmp/audit_doctor_snapshot_stderr.XXXXXX)" + local json rc + json="$("$fn_bin" doctor "$sub" --json 2>"$stderr_tmp")" || rc=$? + rc="${rc:-0}" + + if [[ "$rc" -ne 0 ]]; then + cat "$stderr_tmp" >&2 + echo "audit_doctor_snapshot: 'fn doctor $sub' fallo (rc=$rc)" >&2 + rm -f "$stderr_tmp" + return "$rc" + fi + rm -f "$stderr_tmp" + + # --- normalizar con jq (diff estable) --- + local stamp + stamp="$(date -u +%Y%m%dT%H%M%SZ)" + local curr="$dir/${stamp}.json" + local nojson=0 + + if ! echo "$json" | jq -S . > "$curr" 2>/dev/null; then + # salida no es JSON valido -> guardar crudo + printf '%s' "$json" > "$curr" + nojson=1 + fi + + # --- snapshot anterior --- + local prev="$dir/latest.json" + + # --- contar hallazgos actuales --- + local count="?" + if [[ "$nojson" -eq 0 ]]; then + if jq -e 'type == "array"' "$curr" >/dev/null 2>&1; then + count="$(jq 'length' "$curr")" + elif jq -e 'type == "object"' "$curr" >/dev/null 2>&1; then + count="$(jq 'keys | length' "$curr")" + fi + fi + + # --- contar hallazgos previos --- + local prevcount="-" + if [[ -f "$prev" ]]; then + if jq -e 'type == "array"' "$prev" >/dev/null 2>&1; then + prevcount="$(jq 'length' "$prev")" + elif jq -e 'type == "object"' "$prev" >/dev/null 2>&1; then + prevcount="$(jq 'keys | length' "$prev")" + fi + fi + + # --- diff de identidad --- + local new_count=0 + local resolved_count=0 + local new_ids=() + local resolved_ids=() + local diff_label="" + + if [[ ! -f "$prev" ]]; then + diff_label="baseline (sin corrida previa)" + elif [[ "$nojson" -eq 1 ]]; then + if ! diff -q "$prev" "$curr" >/dev/null 2>&1; then + diff_label="changed (textual)" + else + diff_label="+0 new -0 resolved" + fi + else + # extraer IDs estables: .ID o .id + local curr_ids prev_ids + curr_ids="$(jq -r 'if type=="array" then .[].ID // .[].id // empty else to_entries[].value.ID // to_entries[].value.id // empty end' "$curr" 2>/dev/null | sort -u)" + prev_ids="$(jq -r 'if type=="array" then .[].ID // .[].id // empty else to_entries[].value.ID // to_entries[].value.id // empty end' "$prev" 2>/dev/null | sort -u)" + + if [[ -n "$curr_ids" || -n "$prev_ids" ]]; then + # NEW: en curr pero no en prev + local new_raw resolved_raw + new_raw="$(comm -23 <(echo "$curr_ids") <(echo "$prev_ids") 2>/dev/null || true)" + resolved_raw="$(comm -13 <(echo "$curr_ids") <(echo "$prev_ids") 2>/dev/null || true)" + + if [[ -n "$new_raw" ]]; then + mapfile -t new_ids <<< "$new_raw" + fi + if [[ -n "$resolved_raw" ]]; then + mapfile -t resolved_ids <<< "$resolved_raw" + fi + + new_count="${#new_ids[@]}" + resolved_count="${#resolved_ids[@]}" + diff_label="+${new_count} new -${resolved_count} resolved" + else + # sin campo .ID/.id — fallback textual + if ! diff -q "$prev" "$curr" >/dev/null 2>&1; then + diff_label="changed (textual)" + else + diff_label="+0 new -0 resolved" + fi + fi + fi + + # --- resumen a stdout --- + echo "[audit:$sub] count=$count prev=$prevcount $diff_label" + + # listar nuevos (max 8) + if [[ "${#new_ids[@]}" -gt 0 ]]; then + local listed=("${new_ids[@]:0:8}") + local extra=$(( ${#new_ids[@]} - 8 )) + local line + line="$(IFS=', '; echo "${listed[*]}")" + if [[ "$extra" -gt 0 ]]; then + line="${line} (+${extra} más)" + fi + echo " NEW: $line" + fi + + # listar resueltos (max 8) + if [[ "${#resolved_ids[@]}" -gt 0 ]]; then + local listed_r=("${resolved_ids[@]:0:8}") + local extra_r=$(( ${#resolved_ids[@]} - 8 )) + local line_r + line_r="$(IFS=', '; echo "${listed_r[*]}")" + if [[ "$extra_r" -gt 0 ]]; then + line_r="${line_r} (+${extra_r} más)" + fi + echo " RESOLVED: $line_r" + fi + + # --- actualizar puntero latest --- + cp "$curr" "$prev" + + # --- retención: borrar snapshots fechados > 30 días --- + find "$dir" -maxdepth 1 -name '*.json' ! -name 'latest.json' -mtime +30 -delete 2>/dev/null || true + + return 0 +} + +# Permitir ejecución directa +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + audit_doctor_snapshot "$@" +fi diff --git a/dev/issues/0167-fn-run-library-go-paquete-entero.md b/dev/issues/0167-fn-run-library-go-paquete-entero.md new file mode 100644 index 00000000..e88fe627 --- /dev/null +++ b/dev/issues/0167-fn-run-library-go-paquete-entero.md @@ -0,0 +1,130 @@ +--- +id: "0167" +title: "fn run de library function Go ejecuta go test del paquete entero (arrastra tests flaky vecinos)" +status: pendiente +type: enhancement +domain: + - registry-quality +scope: registry-only +priority: media +depends: [] +blocks: [] +related: ["0077"] +created: 2026-06-03 +updated: 2026-06-03 +tags: [fn-run, go, testing, flaky, dag-engine, reliability] +--- +# 0167 — fn run de library function Go ejecuta go test del paquete entero + +## APP Metadata + +| Campo | Valor | +|-------|-------| +| **ID** | 0167 | +| **Estado** | pendiente | +| **Prioridad** | media | +| **Tipo** | enhancement — dispatcher de `fn run` | + +## Contexto + +Cuando `fn run ` recibe una **library function Go sin `main.go`** que tiene tests +declarados (`tested: true` + `test_file_path`), el dispatcher (`cmd/fn/run.go:171-181`) +ejecuta: + +``` +go test -v -count=1 -tags fts5 ./functions/ # el PAQUETE ENTERO +``` + +Es decir, no ejecuta "la función" (no se puede: no tiene `main`), sino que corre **todos +los tests del paquete**. Consecuencia: el éxito de `fn run miFuncion` depende de que pasen +los tests de **todas las demás funciones del mismo paquete**, no solo los suyos. + +### Cómo se manifestó + +Los DAGs `daily-registry-audit` y `weekly-deep-scan` del `dag_engine` invocaban funciones +`*_go_infra` (`find_unused_functions`, `artefact_doctor`, etc.) como `function:` steps. +Cada step disparaba `go test ./functions/infra` (paquete completo), que contiene tests +impuros con recursos fijos: + +- `TestSSHTunnelOpenClose` → `bind [127.0.0.1]:19876: Address already in use` +- `TestDockerContainerExec` → `listen unix .../docker_exec_test.sock: bind: invalid argument` (path de socket > 108 chars con TMPDIR largo) + +Al correr dos `function:` steps en paralelo (ambos `depends` del mismo padre), las dos +invocaciones de `go test ./functions/infra` colisionaban en el **puerto fijo 19876** → +una pasaba y la otra fallaba de forma no determinista. Resultado: el DAG fallaba sin +auditar nada, y el fallo parecía "la auditoría encontró un problema" cuando en realidad +era un test de red vecino. + +> Nota: el síntoma operativo en los DAGs ya se resolvió por otra vía (2026-06-03): los +> steps ahora usan `audit_doctor_snapshot_bash_infra` (Bash), que ejecuta `fn doctor ` +> real en vez de `go test` del paquete. Este issue es la **causa raíz general** del +> dispatcher, que sigue afectando a cualquier `fn run `. + +## Problema + +1. `fn run` de una library function NO ejecuta la función — corre el paquete de test entero. +2. Los tests impuros de un paquete (puertos/sockets/red fijos) no son seguros para + ejecuciones concurrentes ni reproducibles en cualquier entorno (TMPDIR, CI). +3. Un único test flaky en `functions/infra` rompe `fn run` de las ~N funciones testeadas + del paquete, y por extensión cualquier DAG/cron que las invoque. + +## Opciones de solución (decidir en implementación) + +### Opción A — library Go sin main → siempre compile-check (`go vet`/`go build`) +`fn run ` significa "verifica que la función va"; para código sin `main` eso es +"compila". Testear es responsabilidad de `go test` / CI, no de `fn run` en un cron. + +- **Pro**: determinista, rápido, elimina el flaky de raíz. +- **Contra**: rompe el comportamiento documentado en `CLAUDE.md` ("`fn run filter_slice_go_core` + → Go function con tests → `go test -v`"). Perderíamos la capacidad de correr los tests de + una función vía `fn run`. + +### Opción B — go test acotado con `-run` a los tests de la función +Si la función declara sus tests, ejecutar solo esos: + +``` +go test -v -count=1 -tags fts5 -run '^(TestX|TestY)$' ./functions/ +``` + +- **Pro**: aísla del flaky vecino manteniendo "fn run corre mis tests". +- **Contra / RIESGO**: si los nombres de `fn.Tests` (frontmatter YAML, `registry/parser.go:32`) + tienen **drift** respecto al código, `-run` no matchea y `go test` sale 0 con + "no tests to run" → **falso-verde** en una primitiva crítica de todo el ecosistema. + Mitigación obligatoria si se elige B: reconciliar `fn.Tests` con los tests extraídos por + el indexer (`registry/test_parser.go::parseGoTests`, que ya puebla `unit_tests`) y/o + detectar "0 tests ejecutados" parseando el output y tratarlo como fallo. + +### Opción C — aislar los tests impuros del paquete +Hacer robustos los tests culpables: puerto efímero (`:0` en vez de `19876`), socket en path +corto bajo `/tmp` con nombre acotado, `t.Parallel`-safe. No cambia el dispatcher pero reduce +la probabilidad de colisión. + +- **Pro**: no toca `fn run` (cero blast radius sistémico). +- **Contra**: no resuelve el problema conceptual (sigue corriendo el paquete entero); otros + paquetes pueden introducir tests impuros nuevos y reincidir. + +## Recomendación + +Combinar **C** (saneamiento inmediato de `TestSSHTunnelOpenClose` y `TestDockerContainerExec`, +bajo riesgo) con **B** endurecida (acotar `-run` + guard anti-falso-verde apoyado en +`unit_tests` extraídos, no en el frontmatter manual). La Opción A es la más limpia +conceptualmente pero rompe comportamiento documentado; evaluar si ese comportamiento +("fn run corre los tests") aún se usa de verdad o puede deprecarse hacia `go test` directo. + +## Definition of Done + +| Escenario | Tipo | Comando / evidencia | Resultado esperado | +|---|---|---|---| +| Golden: `fn run` de library fn testeada | e2e | `./fn run find_unused_functions_go_infra` | exit 0 sin depender de tests de funciones vecinas | +| Edge: dos `fn run` concurrentes del mismo paquete | e2e | dos invocaciones en paralelo de funciones de `functions/infra` | ambas exit 0, sin colisión de puerto/socket | +| Error: nombres de test con drift (si se elige B) | unit | `fn.Tests` con un nombre inexistente | NO produce falso-verde (se detecta "0 tests run" → fallo) | +| Tests impuros saneados | unit | `go test -run 'TestSSHTunnelOpenClose\|TestDockerContainerExec' ./functions/infra` repetido 5× | 5/5 PASS deterministas | + +## Notas + +- Archivos clave: `cmd/fn/run.go` (dispatcher, líneas 145-194), `registry/parser.go` + (campo `Tests`), `registry/test_parser.go` (extracción de nombres de test), + `functions/infra/ssh_tunnel_open_close_test.go` y `functions/infra/docker_container_exec_test.go` + (tests culpables). +- Relacionado con 0077 (fn-run-bash-output-mudo): familia de issues sobre la semántica y + observabilidad de `fn run`. diff --git a/functions/infra/resolve_registry_root.go b/functions/infra/resolve_registry_root.go new file mode 100644 index 00000000..eda355e5 --- /dev/null +++ b/functions/infra/resolve_registry_root.go @@ -0,0 +1,61 @@ +package infra + +import ( + "fmt" + "os" + "path/filepath" +) + +// ResolveRegistryRoot returns the absolute path of the fn_registry root +// (the directory that contains registry.db), or an error if not found. +// +// Resolution order: +// 1. FN_REGISTRY_ROOT env var — if set and /registry.db exists, return it. +// 2. Walk up from the executable's directory — up to 6 levels; first dir +// that contains registry.db wins. Covers binaries at /fn and at +// /apps//. +// 3. $HOME/fn_registry — if <$HOME>/fn_registry/registry.db exists. +// 4. Error — no method found a valid registry root. +func ResolveRegistryRoot() (string, error) { + // 1. Env var + if envDir := os.Getenv("FN_REGISTRY_ROOT"); envDir != "" { + if nonEmptyFile(filepath.Join(envDir, "registry.db")) { + return filepath.Clean(envDir), nil + } + } + + // 2. Walk up from executable + if exe, err := os.Executable(); err == nil { + dir := filepath.Dir(exe) + for i := 0; i < 6; i++ { + if nonEmptyFile(filepath.Join(dir, "registry.db")) { + return dir, nil + } + parent := filepath.Dir(dir) + if parent == dir { + // Reached filesystem root + break + } + dir = parent + } + } + + // 3. $HOME/fn_registry + if home, err := os.UserHomeDir(); err == nil { + candidate := filepath.Join(home, "fn_registry") + if nonEmptyFile(filepath.Join(candidate, "registry.db")) { + return candidate, nil + } + } + + return "", fmt.Errorf("resolve_registry_root: no se encontro registry.db (set FN_REGISTRY_ROOT)") +} + +// nonEmptyFile reports whether path is an existing, non-empty regular file. +// A zero-byte registry.db — which sql.Open silently creates when handed a +// mis-resolved path — must NOT count as a valid registry root, otherwise such +// a shadow file would hijack the walk-up and mask the real database. +func nonEmptyFile(path string) bool { + fi, err := os.Stat(path) + return err == nil && fi.Mode().IsRegular() && fi.Size() > 0 +} diff --git a/functions/infra/resolve_registry_root.md b/functions/infra/resolve_registry_root.md new file mode 100644 index 00000000..69749a81 --- /dev/null +++ b/functions/infra/resolve_registry_root.md @@ -0,0 +1,51 @@ +--- +name: resolve_registry_root +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func ResolveRegistryRoot() (string, error)" +description: "Devuelve la ruta absoluta de la raíz del fn_registry (el directorio que contiene registry.db). Resuelve en orden: env var FN_REGISTRY_ROOT, walk-up desde el ejecutable (hasta 6 niveles), y $HOME/fn_registry. Retorna error si ningún método localiza registry.db. Centraliza la resolución dinámica para que ningún binario del ecosistema necesite un path hardcodeado." +tags: [registry, infra, root, path, env] +uses_functions: [] +uses_types: [] +params: + - name: (ninguno) + desc: "La función no recibe argumentos." +output: "Ruta absoluta del directorio raíz del fn_registry (contiene registry.db), o error descriptivo si no se encontró." +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: ["fmt", "os", "path/filepath"] +tested: true +tests: + - "env var apunta a tmpdir con registry.db" + - "HOME apunta a tmpdir con fn_registry/registry.db" + - "nada existe devuelve error" +test_file_path: "functions/infra/resolve_registry_root_test.go" +file_path: "functions/infra/resolve_registry_root.go" +--- + +## Ejemplo + +```go +root, err := infra.ResolveRegistryRoot() +if err != nil { + log.Fatalf("no se pudo localizar fn_registry: %v", err) +} +// Construir ruta al binario fn +fnBin := filepath.Join(root, "fn") +cmd := exec.Command(fnBin, "run", "mi_funcion") +``` + +## Cuando usarla + +Cuando un binario Go del ecosistema (dag_engine, deploy_server, registry_mcp, etc.) necesita localizar la raíz del registry para construir rutas a `fn run`, abrir `registry.db`, o encontrar `python/.venv` — y `FN_REGISTRY_ROOT` puede no estar seteado en el entorno del usuario. + +## Gotchas + +- **Depende del entorno en tiempo de ejecución** (env vars + filesystem): no es determinista. Dos llamadas sucesivas desde entornos distintos pueden devolver rutas distintas. +- **El paso 2 (walk-up desde ejecutable) asume que el binario vive dentro del árbol del registry.** Si el binario se copia a `/usr/local/bin` o a un directorio externo, este paso no encontrará nada — los pasos 1 y 3 sirven como fallback. +- **En `go test` el exe es un binario temporal** generado por el framework de tests, que vive en `/tmp/`. Por eso el paso 2 no encontrará `registry.db` durante los tests unitarios. Los tests de esta función usan `t.Setenv` para cubrir los pasos 1 y 3 sin depender del entorno real. +- **El paso 3 deriva `$HOME` dinámicamente** con `os.UserHomeDir()` — nunca hay paths de usuario hardcodeados. diff --git a/functions/infra/resolve_registry_root_test.go b/functions/infra/resolve_registry_root_test.go new file mode 100644 index 00000000..146caec9 --- /dev/null +++ b/functions/infra/resolve_registry_root_test.go @@ -0,0 +1,60 @@ +package infra + +import ( + "os" + "path/filepath" + "testing" +) + +func TestResolveRegistryRoot(t *testing.T) { + t.Run("env var apunta a tmpdir con registry.db", func(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "registry.db") + if err := os.WriteFile(dbPath, []byte("SQLite format 3\x00"), 0644); err != nil { + t.Fatal(err) + } + t.Setenv("FN_REGISTRY_ROOT", dir) + + got, err := ResolveRegistryRoot() + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if got != filepath.Clean(dir) { + t.Errorf("got %q, want %q", got, filepath.Clean(dir)) + } + }) + + t.Run("HOME apunta a tmpdir con fn_registry/registry.db", func(t *testing.T) { + homeDir := t.TempDir() + registryDir := filepath.Join(homeDir, "fn_registry") + if err := os.MkdirAll(registryDir, 0755); err != nil { + t.Fatal(err) + } + dbPath := filepath.Join(registryDir, "registry.db") + if err := os.WriteFile(dbPath, []byte("SQLite format 3\x00"), 0644); err != nil { + t.Fatal(err) + } + // Clear env var so we fall through to the HOME check. + t.Setenv("FN_REGISTRY_ROOT", "") + t.Setenv("HOME", homeDir) + + got, err := ResolveRegistryRoot() + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if got != registryDir { + t.Errorf("got %q, want %q", got, registryDir) + } + }) + + t.Run("nada existe devuelve error", func(t *testing.T) { + emptyHome := t.TempDir() + t.Setenv("FN_REGISTRY_ROOT", "") + t.Setenv("HOME", emptyHome) + + _, err := ResolveRegistryRoot() + if err == nil { + t.Error("expected error when nothing exists, got nil") + } + }) +}