Compare commits

..

2 Commits

Author SHA1 Message Date
Egutierrez 9a7a874a76 fix(infra): audit_uses_functions detecta imports Python anidados y multilinea (0056)
El parser Python de audit_uses_functions solo reconocia "from <pkg> import X"
con un unico componente de paquete (regex \w+), por lo que:

- "from <pkg>.<subpkg> import X" (import anidado) no matcheaba y la funcion se
  reportaba como falso unused_in_app_md.
- Las listas multilinea con parentesis "from <pkg> import (\n a,\n b,\n)" no se
  parseaban (escaneo linea a linea).

Cambios:
- Regex acepta puntos en el paquete y bloques parentizados multilinea.
- Resolucion validada contra el directorio de paquete del registry derivado de
  file_path (no del campo domain: las funciones metabase viven en
  python/functions/metabase/ pero tienen domain=infra). Imports de librerias
  externas se ignoran -> sin falsos missing.
- parsePyImportedSymbols descarta comentarios "# noqa", maneja "as alias" y
  star imports (tratados como vacio, no soportados por diseno).
- auditFnMeta carga file_path; query SELECT anade file_path.

Tests (functions/infra/audit_uses_functions_test.go):
- TestAuditUsesFunctions_DetectsNestedImport (golden)
- TestAuditUsesFunctions_NoFalsePositiveOnNested (edge: nested + multilinea)
- TestAuditUsesFunctions_StarImport (error/edge: star import no cuenta)

Verificado con fn doctor uses-functions sobre apps reales: drift baja de 11/42 a
9/42. mail_manager (9 falsos por "from infra.X import Y") y demand_radar (3 por
lista multilinea) quedan en 0 drift. El residual de osint_db/osint_web es carga
dinamica via importlib, documentado como fuera de alcance.

audit_uses_functions v1.0.0 -> v1.1.0. CHANGELOG actualizado.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 13:10:31 +02:00
egutierrez 5501507588 feat(infra): launch_fleetclaude auto-detecta terminal (kitty ↔ Windows Terminal)
La ruta ventana-nueva ya no asume kitty. Elige terminal según el host, sin
config por PC: kitty si está instalado y hay display ($DISPLAY/$WAYLAND_DISPLAY);
si no, en WSL abre Windows Terminal (wt.exe) ejecutando
`wsl.exe [-d $WSL_DISTRO_NAME] -- bash -lic 'tmux ... attach'`.

Arregla el síntoma "se lanza la flota pero no se ve": en WSL sin kitty la sesión
tmux se creaba pero ninguna ventana la mostraba. Mismo `fleetclaude` funciona en
un PC con kitty y en otro WSL sin kitty.

wt.exe se lanza desde un subshell con cwd /mnt/c para evitar el warning por cwd
UNC (\\wsl.localhost\...). El path de attach interactivo (terminal real fuera de
tmux) queda intacto. Bump 1.5.0 -> 1.6.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 12:50:20 +02:00
6 changed files with 399 additions and 450 deletions
+4
View File
@@ -8,6 +8,10 @@ Para contexto detallado del trabajo diario ver `docs/diary/`. Para decisiones ar
## [Unreleased] ## [Unreleased]
### Fixed
- **`audit_uses_functions` detecta imports Python anidados y multilinea** (issue 0056) — el parser Python ahora reconoce `from <pkg>.<subpkg> import X` (antes la regex `\w+` rompia ante el punto y la funcion se reportaba como falso `unused_in_app_md`) y listas multilinea con parentesis `from <pkg> import (\n a,\n b,\n)`. La resolucion se valida contra el directorio de paquete del registry derivado de `file_path` (no del campo `domain`: las funciones `metabase` viven en `python/functions/metabase/` pero tienen `domain=infra`), e ignora imports de librerias externas. Aliases (`as`) y comentarios (`# noqa`) se descartan. Star imports (`from pkg import *`) y carga dinamica (`importlib`) quedan documentados como no soportados. Verificado: `fn doctor uses-functions` baja de 11/42 a 9/42 apps con drift — `mail_manager` (9 falsos positivos por `from infra.X import Y`) y `demand_radar` (3 por lista multilinea `from datascience import (...)`) quedan en 0 drift; el residual de `osint_db`/`osint_web` es carga dinamica via wrapper, fuera de alcance. `audit_uses_functions` v1.0.0 → v1.1.0.
## 2026-05-17 ## 2026-05-17
### Added ### Added
+35 -14
View File
@@ -3,11 +3,11 @@ name: launch_fleetclaude
kind: function kind: function
lang: bash lang: bash
domain: infra domain: infra
version: "1.5.0" version: "1.6.0"
purity: impure purity: impure
signature: "launch_fleetclaude [--cwd <dir>] [--bin <path>] [--session <name>] [--reuse] [--cols <n>]" signature: "launch_fleetclaude [--cwd <dir>] [--bin <path>] [--session <name>] [--reuse] [--cols <n>]"
description: "Entrypoint de FleetView: abre una ventana kitty con una sesion tmux (socket aislado por perfil) de dos panes (TUI fleetview a la izquierda, claude --dangerously-skip-permissions a la derecha) para centralizar la flota de Claudes. El pane de la TUI corre dentro del bucle supervisor supervise_fleetview_tui, que la relanza si muere (crash/panic/kill), asi el panel de control NUNCA se pierde. Soporta PERFILES multiples: sin --session/--reuse cada invocacion abre un perfil nuevo (fleet, fleet2, fleet3, ...) con su propia flota; inyecta FLEET_SOCKET/FLEET_SESSION a la TUI para que cada panel vea solo sus Claudes. Instala atajos alt+flechas/alt+enter/alt+n que controlan la TUI desde cualquier pane, y fija el ancho del sidebar con hooks." description: "Entrypoint de FleetView: abre una ventana de terminal con una sesion tmux (socket aislado por perfil) de dos panes (TUI fleetview a la izquierda, claude --dangerously-skip-permissions a la derecha) para centralizar la flota de Claudes. La terminal se AUTO-DETECTA sin config por PC: kitty si esta instalado y hay display ($DISPLAY/$WAYLAND_DISPLAY), si no Windows Terminal (wt.exe) en WSL adjuntando via wsl.exe. El pane de la TUI corre dentro del bucle supervisor supervise_fleetview_tui, que la relanza si muere (crash/panic/kill), asi el panel de control NUNCA se pierde. Soporta PERFILES multiples: sin --session/--reuse cada invocacion abre un perfil nuevo (fleet, fleet2, fleet3, ...) con su propia flota; inyecta FLEET_SOCKET/FLEET_SESSION a la TUI para que cada panel vea solo sus Claudes. Instala atajos alt+flechas/alt+enter/alt+n que controlan la TUI desde cualquier pane, y fija el ancho del sidebar con hooks."
tags: [claude-fleet, infra, kitty, tmux, claude, fleetview, launcher] tags: [claude-fleet, infra, kitty, tmux, claude, fleetview, launcher, wsl, windows-terminal]
params: params:
- name: --cwd - name: --cwd
desc: "Directorio de trabajo de ambos panes tmux. Opcional. Default: raiz del repo fn_registry, derivada dinamicamente via git rev-parse desde la ubicacion del script (sin hardcodear paths de usuario)." desc: "Directorio de trabajo de ambos panes tmux. Opcional. Default: raiz del repo fn_registry, derivada dinamicamente via git rev-parse desde la ubicacion del script (sin hardcodear paths de usuario)."
@@ -19,7 +19,7 @@ params:
desc: "Reattach al perfil principal 'fleet' en vez de abrir uno nuevo. Opcional. Recupera el comportamiento idempotente clasico (volver a invocar NO duplica la flota, reusa la existente)." desc: "Reattach al perfil principal 'fleet' en vez de abrir uno nuevo. Opcional. Recupera el comportamiento idempotente clasico (volver a invocar NO duplica la flota, reusa la existente)."
- name: --cols - name: --cols
desc: "Ancho en columnas del pane izquierdo (la TUI). Opcional. Default: 40." desc: "Ancho en columnas del pane izquierdo (la TUI). Opcional. Default: 40."
output: "Crea/reutiliza una sesion tmux detached con dos panes y lanza una ventana kitty 'FleetView' adjunta a ella, desacoplada del shell padre (setsid). Imprime el estado por stdout. Sin valor de retorno; exit 0 en exito." output: "Crea/reutiliza una sesion tmux detached con dos panes y lanza una ventana de terminal 'FleetView' adjunta a ella (kitty o Windows Terminal segun auto-deteccion), desacoplada del shell padre. Imprime el estado por stdout. Sin valor de retorno; exit 0 en exito."
uses_functions: uses_functions:
- supervise_fleetview_tui_bash_infra - supervise_fleetview_tui_bash_infra
uses_types: [] uses_types: []
@@ -49,7 +49,7 @@ launch_fleetclaude --reuse
launch_fleetclaude --session trabajo --cols 50 launch_fleetclaude --session trabajo --cols 50
``` ```
Tras invocarlo aparece una ventana kitty titulada `FleetView (<perfil>)` con dos Tras invocarlo aparece una ventana de terminal titulada `FleetView (<perfil>)` con dos
panes lado a lado: a la izquierda la TUI `fleetview`, a la derecha una sesion de panes lado a lado: a la izquierda la TUI `fleetview`, a la derecha una sesion de
`claude --dangerously-skip-permissions`. Cada perfil es un socket+sesion tmux `claude --dangerously-skip-permissions`. Cada perfil es un socket+sesion tmux
aislados con su propia flota: puedes tener varias FleetView abiertas a la vez. aislados con su propia flota: puedes tener varias FleetView abiertas a la vez.
@@ -78,12 +78,24 @@ al retomar el trabajo en el repo `fn_registry`.
`respawn-pane` de alt+R y los Claude nuevos hereden el socket). `main.go` los `respawn-pane` de alt+R y los Claude nuevos hereden el socket). `main.go` los
lee con fallback a `fleet`. Por eso cada panel ve SOLO los Claude de su perfil lee con fallback a `fleet`. Por eso cada panel ve SOLO los Claude de su perfil
(cruza la lista del sistema con los panes de su socket). (cruza la lista del sistema con los panes de su socket).
- **Auto-deteccion de terminal (sin config por PC)**: en la ruta ventana-nueva el
launcher elige terminal solo. (1) `kitty` instalado **y** display usable
(`$DISPLAY`/`$WAYLAND_DISPLAY`) → kitty (escritorio Linux nativo o WSLg con
kitty). (2) Si no, WSL con `wt.exe` en el PATH → Windows Terminal ejecutando
`wsl.exe [-d $WSL_DISTRO_NAME] -- bash -lic 'tmux -L <perfil> attach ...'`.
(3) Ninguna → error con las salidas posibles. Asi el MISMO `fleetclaude`
funciona en un PC con kitty y en otro WSL sin kitty, cada uno elige su
terminal. Causa raiz del sintoma "se lanza la flota pero no se ve": kitty no
instalado en WSL hacia que la sesion tmux se creara sin ventana que la mostrara.
- **Dentro de tmux abre ventana nueva**: si invocas `fleetclaude` desde dentro de - **Dentro de tmux abre ventana nueva**: si invocas `fleetclaude` desde dentro de
una sesion tmux (`$TMUX` definido), NO hace `attach` anidado (rompe / avisa de una sesion tmux (`$TMUX` definido), NO hace `attach` anidado (rompe / avisa de
nesting); cae a la ruta kitty y abre una ventana nueva. Fuera de tmux y con nesting); cae a la ruta ventana-nueva (auto-deteccion de terminal). Fuera de
TTY, reutiliza la terminal actual con `exec tmux attach`. tmux y con TTY, reutiliza la terminal actual con `exec tmux attach`.
- **kitty detached (setsid)**: la ventana se lanza con `setsid ... &` para - **kitty detached (setsid)**: la ventana kitty se lanza con `setsid ... &` para
sobrevivir al cierre de la terminal que la invoco. No bloquea al shell padre. sobrevivir al cierre de la terminal que la invoco. La ventana de Windows
Terminal (wt.exe) ya es un proceso Windows independiente del arbol Linux, asi
que sobrevive sola (se lanza con `&`+`disown` desde un subshell con cwd `/mnt/c`
para evitar el warning de wt.exe por cwd UNC `\\wsl.localhost\...`).
- **TUI bajo supervisor (auto-respawn)**: el pane izquierdo NO corre un - **TUI bajo supervisor (auto-respawn)**: el pane izquierdo NO corre un
`exec fleetview` de una sola vida, sino `supervise_fleetview_tui` (bucle que `exec fleetview` de una sola vida, sino `supervise_fleetview_tui` (bucle que
relanza la TUI si muere por crash/panic/kill). Asi el panel de control nunca se relanza la TUI si muere por crash/panic/kill). Asi el panel de control nunca se
@@ -116,14 +128,23 @@ al retomar el trabajo en el repo `fn_registry`.
- **Ancho del sidebar via hooks**: `client-resized` y `window-layout-changed` - **Ancho del sidebar via hooks**: `client-resized` y `window-layout-changed`
re-fijan el pane 0 (TUI) a `--cols` columnas, porque el `attach` de kitty y el re-fijan el pane 0 (TUI) a `--cols` columnas, porque el `attach` de kitty y el
conmutar de Claude redistribuyen el espacio. conmutar de Claude redistribuyen el espacio.
- **tmux siempre, kitty solo sin TTY**: `tmux` es obligatorio (aborta != 0 si - **tmux siempre; terminal (kitty/wt.exe) solo sin TTY**: `tmux` es obligatorio
falta). `kitty` solo se necesita en la ruta sin-TTY (atajo de escritorio, cron, (aborta != 0 si falta). Una terminal nueva (kitty o Windows Terminal) solo se
script), donde abre una ventana nueva. Invocado desde una terminal interactiva necesita en la ruta sin-TTY (dentro de tmux, atajo de escritorio, cron, script),
(el caso normal del alias `fleetclaude`), reutiliza la terminal actual con donde abre una ventana nueva. Invocado desde una terminal interactiva fuera de
`exec tmux attach` y NO necesita kitty — util en WSL u hosts sin kitty. tmux (el caso normal del alias `fleetclaude`), reutiliza la terminal actual con
`exec tmux attach` y no necesita ni kitty ni wt.exe.
## Capability growth log ## Capability growth log
- v1.6.0 (2026-06-29) — **auto-deteccion de terminal (kitty ↔ Windows Terminal)**.
La ruta ventana-nueva ya no asume kitty: elige terminal segun el host. kitty si
esta instalado y hay display (`$DISPLAY`/`$WAYLAND_DISPLAY`); si no, en WSL abre
Windows Terminal (`wt.exe`) ejecutando `wsl.exe [-d $WSL_DISTRO_NAME] -- bash
-lic 'tmux ... attach'`. Mismo `fleetclaude` en un PC con kitty y en otro WSL
sin kitty. Arregla el sintoma "se lanza la flota pero no se ve": en WSL sin
kitty la sesion tmux se creaba pero ninguna ventana la mostraba. wt.exe se
lanza desde un subshell con cwd `/mnt/c` para evitar el warning por cwd UNC.
- v1.5.0 (2026-06-24) — **auto-respawn de la TUI**. El pane izquierdo ya no corre - v1.5.0 (2026-06-24) — **auto-respawn de la TUI**. El pane izquierdo ya no corre
`exec fleetview` (una sola vida), sino el bucle supervisor `exec fleetview` (una sola vida), sino el bucle supervisor
`supervise_fleetview_tui`, que relanza la TUI si muere (crash/panic/kill de su `supervise_fleetview_tui`, que relanza la TUI si muere (crash/panic/kill de su
+41 -11
View File
@@ -294,31 +294,61 @@ USAGE
$T set-hook -g window-layout-changed "resize-pane -t $left_pane -x $cols" $T set-hook -g window-layout-changed "resize-pane -t $left_pane -x $cols"
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
# Lanzar kitty adjuntando la sesion, DESACOPLADA del shell padre con # Adjuntar la sesion en una terminal, DESACOPLADA del shell padre para que
# setsid, para que no muera al cerrar la terminal invocadora. # no muera al cerrar la terminal invocadora.
# (Mismo patron que reboot_all_claudes para relanzar terminales.)
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
# Adjuntar la sesion: # Adjuntar la sesion:
# - Terminal interactiva y FUERA de tmux: convertir ESA terminal en el # - Terminal interactiva y FUERA de tmux: convertir ESA terminal en el
# panel FleetView (exec reemplaza el proceso; al hacer detach vuelve la # panel FleetView (exec reemplaza el proceso; al hacer detach vuelve la
# shell). Asi `fleetclaude` no abre otra ventana: usa la actual. # shell). Asi `fleetclaude` no abre otra ventana: usa la actual.
# - DENTRO de tmux (o sin TTY: atajo de escritorio, cron, script): abrir # - DENTRO de tmux (o sin TTY: atajo de escritorio, cron, script): abrir
# una ventana kitty nueva desacoplada (setsid). No hacemos `attach` # una ventana de terminal NUEVA desacoplada. No hacemos `attach`
# anidado dentro de otra sesion tmux (rompe / da el warning de nesting). # anidado dentro de otra sesion tmux (rompe / da el warning de nesting).
if [ -t 0 ] && [ -t 1 ] && [ -z "${TMUX:-}" ]; then if [ -t 0 ] && [ -t 1 ] && [ -z "${TMUX:-}" ]; then
exec tmux -L "$session" attach -t "$session" exec tmux -L "$session" attach -t "$session"
fi fi
# Ruta ventana-nueva: necesitamos kitty para abrirla.
if ! command -v kitty >/dev/null 2>&1; then # -----------------------------------------------------------------------
echo "launch_fleetclaude: kitty no esta instalado (necesario para abrir ventana nueva)." >&2 # Ruta ventana-nueva: AUTO-DETECTAR la terminal disponible (sin config por
echo "launch_fleetclaude: lanzalo desde una terminal interactiva fuera de tmux, o instala kitty." >&2 # PC). El mismo `fleetclaude` funciona en un escritorio Linux con kitty y en
return 1 # un WSL sin kitty pero con Windows Terminal.
fi # 1. kitty instalado + display usable ($DISPLAY/$WAYLAND_DISPLAY) -> kitty
# (escritorio Linux nativo, o WSLg con kitty instalado).
# 2. WSL con wt.exe alcanzable -> Windows Terminal ejecutando wsl.exe que
# adjunta la sesion tmux (PCs WSL sin kitty: la ventana kitty nunca
# aparece sin una terminal Linux real, por eso "se lanza pero no se ve").
# 3. Ninguna -> error claro con las dos salidas posibles.
# -----------------------------------------------------------------------
if command -v kitty >/dev/null 2>&1 && [[ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ]]; then
setsid kitty --title "FleetView ($session)" -e tmux -L "$session" attach -t "$session" </dev/null >/dev/null 2>&1 & setsid kitty --title "FleetView ($session)" -e tmux -L "$session" attach -t "$session" </dev/null >/dev/null 2>&1 &
disown 2>/dev/null || true disown 2>/dev/null || true
echo "launch_fleetclaude: ventana kitty 'FleetView ($session)' adjunta al perfil '$session'." echo "launch_fleetclaude: ventana kitty 'FleetView ($session)' adjunta al perfil '$session'."
return 0 return 0
fi
if command -v wt.exe >/dev/null 2>&1; then
# bash -lic <attach> dentro de wsl.exe: login+interactive para que tmux y
# el PATH del perfil esten disponibles en la ventana de Windows Terminal.
local attach_cmd
attach_cmd="tmux -L $(printf '%q' "$session") attach -t $(printf '%q' "$session")"
local distro="${WSL_DISTRO_NAME:-}"
local wsl_args=(wsl.exe)
[[ -n "$distro" ]] && wsl_args+=(-d "$distro")
wsl_args+=(-- bash -lic "$attach_cmd")
# cd a una ruta Windows (/mnt/c) evita el warning de wt.exe por cwd UNC
# (\\wsl.localhost\...). El cwd real de los panes lo fija la sesion tmux.
( cd /mnt/c 2>/dev/null || cd /
wt.exe new-tab --title "FleetView ($session)" "${wsl_args[@]}" </dev/null >/dev/null 2>&1 &
disown 2>/dev/null || true )
echo "launch_fleetclaude: Windows Terminal 'FleetView ($session)' adjunta al perfil '$session' (WSL distro '${distro:-default}')."
return 0
fi
echo "launch_fleetclaude: no hay terminal para abrir una ventana nueva." >&2
echo "launch_fleetclaude: - escritorio Linux: instala kitty y exporta DISPLAY/WAYLAND_DISPLAY." >&2
echo "launch_fleetclaude: - WSL: usa Windows Terminal (wt.exe debe estar en el PATH)." >&2
echo "launch_fleetclaude: - o lanza fleetclaude desde una terminal interactiva fuera de tmux." >&2
return 1
} }
# Permitir ejecutar el archivo directamente (no solo como funcion sourced). # Permitir ejecutar el archivo directamente (no solo como funcion sourced).
+122 -149
View File
@@ -30,7 +30,7 @@ type auditFnMeta struct {
domain string domain string
lang string lang string
signature string signature string
filePath string // registry-relative path to the .go source (Go funcs only) filePath string // file_path as stored in registry.db (used to derive the Python package dir)
} }
// skipDirs are directory names ignored when walking source for audits. // skipDirs are directory names ignored when walking source for audits.
@@ -63,9 +63,11 @@ func auditShouldSkipDir(name string) bool { return auditSkipDirs[name] }
// searches the source for the exported symbol derived from each function name // searches the source for the exported symbol derived from each function name
// (snake_case → PascalCase) to achieve per-function granularity within a package. // (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 // For Python apps it scans for "from <pkg> import X" patterns where the root of
// a known registry domain, then resolves X to a function ID by matching the name // <pkg> matches a registry Python package directory (derived from file_path),
// field in registry.db. // then resolves each imported symbol to a function ID by name within that package.
// Both flat ("from metabase import X") and nested ("from metabase.cards import X")
// imports are handled, as are parenthesised multi-line lists.
// //
// Returns an error only if registry.db cannot be opened. Apps where dir_path // 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). // does not exist on disk are reported with Missing/Unused = nil (cannot inspect).
@@ -81,8 +83,7 @@ func AuditUsesFunctions(registryRoot string) ([]UsesFunctionsAudit, error) {
return nil, fmt.Errorf("audit_uses_functions: ping db: %w", err) return nil, fmt.Errorf("audit_uses_functions: ping db: %w", err)
} }
// Load all Go/Python/TS functions from registry: id → name, domain, lang, // Load all Go/Python/TS functions from registry: id → name, domain, lang, signature, file_path.
// signature, file_path. file_path feeds the Go .go fallback (see auditGoApp).
rows, err := db.Query(`SELECT id, name, domain, lang, COALESCE(signature, ''), COALESCE(file_path, '') FROM functions WHERE lang IN ('go','py','ts')`) rows, err := db.Query(`SELECT id, name, domain, lang, COALESCE(signature, ''), COALESCE(file_path, '') FROM functions WHERE lang IN ('go','py','ts')`)
if err != nil { if err != nil {
return nil, fmt.Errorf("audit_uses_functions: query functions: %w", err) return nil, fmt.Errorf("audit_uses_functions: query functions: %w", err)
@@ -146,7 +147,7 @@ func AuditUsesFunctions(registryRoot string) ([]UsesFunctionsAudit, error) {
switch app.lang { switch app.lang {
case "go": case "go":
importedIDs = append(importedIDs, auditGoApp(absDir, allFunctions, registryRoot)...) importedIDs = append(importedIDs, auditGoApp(absDir, allFunctions)...)
scannedLangs["go"] = true scannedLangs["go"] = true
case "py": case "py":
importedIDs = append(importedIDs, auditPyApp(absDir, allFunctions)...) importedIDs = append(importedIDs, auditPyApp(absDir, allFunctions)...)
@@ -199,18 +200,11 @@ func AuditUsesFunctions(registryRoot string) ([]UsesFunctionsAudit, error) {
// Strategy: // Strategy:
// 1. Find all "fn-registry/functions/<domain>" import paths (production code only). // 1. Find all "fn-registry/functions/<domain>" import paths (production code only).
// 2. For each domain, collect registry functions in that domain. // 2. For each domain, collect registry functions in that domain.
// 3. Grep source files for the exported symbol. Tokens tried, in order: // 3. Grep source files for the exported symbol. The token tried first is the
// a) the real Go func identifier parsed from the registry signature; // real Go func identifier parsed from the registry signature; fallback is
// b) PascalCase(name) (with commonAbbrevs); // PascalCase(name). Many functions deviate (e.g. sqlite_column_exists has
// c) the real exported func read straight from the function's .go file. // `func ColumnExists`), so signature is the source of truth.
// func auditGoApp(appDir string, all map[string]auditFnMeta) []string {
// Many functions deviate from snake_case→PascalCase (e.g. sqlite_column_exists
// has `func ColumnExists`, wails_bind_crud has `func GenerateWailsCRUD`). The
// signature is usually the source of truth, but some signatures omit the `func`
// keyword or list a different primary symbol; step (c) reads the .go file as a
// last-resort fallback so those cases stop being false positives ("unused").
// The .go read is cached per execution to avoid reopening the same file.
func auditGoApp(appDir string, all map[string]auditFnMeta, registryRoot string) []string {
// Step 1: collect imported domains. // Step 1: collect imported domains.
importedDomains := collectGoImportedDomains(appDir) importedDomains := collectGoImportedDomains(appDir)
if len(importedDomains) == 0 { if len(importedDomains) == 0 {
@@ -225,10 +219,6 @@ func auditGoApp(appDir string, all map[string]auditFnMeta, registryRoot string)
return nil return nil
} }
// Cache for the .go fallback: registry file_path → real exported func name.
// Populated lazily, only when the cheaper tokens fail to match.
goFileSymbolCache := make(map[string]string)
for _, m := range all { for _, m := range all {
if m.lang != "go" { if m.lang != "go" {
continue continue
@@ -236,76 +226,17 @@ func auditGoApp(appDir string, all map[string]auditFnMeta, registryRoot string)
if !importedDomains[m.domain] { if !importedDomains[m.domain] {
continue continue
} }
matched := false tokens := goCandidateTokens(m)
for _, tok := range goCandidateTokens(m) { for _, tok := range tokens {
if containsToken(blob, tok) { if containsToken(blob, tok) {
matched = true used = append(used, m.id)
break break
} }
} }
if !matched && goSignatureSymbol(m) == "" {
// Fallback (c): read the registry .go file and look for the real
// exported func name. Gated on an EMPTY signature symbol on purpose:
// when the signature already yields a concrete `func <Name>` it is the
// authoritative symbol, so reading the .go (which can only guess the
// file's first exported func) must not override it. Several registry
// functions share one .go file via the "TU adicional" pattern (e.g.
// cdp_new_tab lives in cdp_list_tabs.go); without this gate the first
// func would be mis-attributed to every sibling and suppress real
// "unused" findings. The file read therefore only happens for the rare
// functions whose stored signature omits the `func` keyword.
if sym := goRealExportedName(registryRoot, m.filePath, goFileSymbolCache); sym != "" {
if containsToken(blob, sym) {
matched = true
}
}
}
if matched {
used = append(used, m.id)
}
} }
return used return used
} }
// goRealExportedFnRe matches a top-level exported func declaration in a .go
// source file: `func Name(` or the generic form `func Name[T any](`. It captures
// the func identifier. Method declarations (`func (r *T) Name(`) are skipped on
// purpose — a registry function's primary symbol is a top-level func, and method
// names would risk spurious matches. Used by the .go fallback to recover the real
// symbol name when the registry signature/name heuristics fail.
var goRealExportedFnRe = regexp.MustCompile(`^func\s+([A-Z][A-Za-z0-9_]*)\s*[\(\[]`)
// goRealExportedName reads the registry .go file at filePath (relative to
// registryRoot) and returns the first exported func identifier found. Results
// are memoised in cache (filePath → symbol, "" when the file is unreadable or
// has no exported func) so a file is opened at most once per audit run.
func goRealExportedName(registryRoot, filePath string, cache map[string]string) string {
if filePath == "" {
return ""
}
if sym, ok := cache[filePath]; ok {
return sym
}
cache[filePath] = "" // pre-seed so an unreadable file is not retried
abs := filePath
if !filepath.IsAbs(abs) {
abs = filepath.Join(registryRoot, filePath)
}
f, err := os.Open(abs)
if err != nil {
return ""
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
if m := goRealExportedFnRe.FindStringSubmatch(sc.Text()); m != nil {
cache[filePath] = m[1]
return m[1]
}
}
return ""
}
// goCandidateTokens returns the identifiers we try when looking for usages // goCandidateTokens returns the identifiers we try when looking for usages
// of a Go function in source. Real exported name from signature first, // of a Go function in source. Real exported name from signature first,
// PascalCase(name) as fallback. // PascalCase(name) as fallback.
@@ -313,8 +244,10 @@ var goSignatureFnRe = regexp.MustCompile(`^\s*func\s+(?:\([^)]*\)\s+)?([A-Z][A-Z
func goCandidateTokens(m auditFnMeta) []string { func goCandidateTokens(m auditFnMeta) []string {
out := []string{} out := []string{}
if sym := goSignatureSymbol(m); sym != "" { if m.signature != "" {
out = append(out, sym) if match := goSignatureFnRe.FindStringSubmatch(m.signature); match != nil {
out = append(out, match[1])
}
} }
pascal := snakeToPascal(m.name) pascal := snakeToPascal(m.name)
if pascal != "" && (len(out) == 0 || out[0] != pascal) { if pascal != "" && (len(out) == 0 || out[0] != pascal) {
@@ -323,21 +256,6 @@ func goCandidateTokens(m auditFnMeta) []string {
return out return out
} }
// goSignatureSymbol returns the exported Go identifier parsed from the registry
// signature (`func Name(...)` or `func (r *T) Name(...)`), or "" when the
// signature is empty or does not start with a `func` declaration. A non-empty
// result is the authoritative symbol for the function and gates off the .go
// fallback in auditGoApp.
func goSignatureSymbol(m auditFnMeta) string {
if m.signature == "" {
return ""
}
if match := goSignatureFnRe.FindStringSubmatch(m.signature); match != nil {
return match[1]
}
return ""
}
// collectGoImportedDomains returns the set of registry domains imported by .go files. // collectGoImportedDomains returns the set of registry domains imported by .go files.
var goImportRe = regexp.MustCompile(`"fn-registry/functions/([a-z]+)"`) var goImportRe = regexp.MustCompile(`"fn-registry/functions/([a-z]+)"`)
@@ -426,16 +344,46 @@ func isIdentRune(r rune) bool {
} }
// auditPyApp returns function IDs detected in the Python source of appDir. // 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+(.+)`) // It recognises "from <pkg> import X, Y" statements where <pkg> is the root of a
// registry package, resolving the imported symbols to function IDs. Both the flat
// form ("from metabase import metabase_get_card") and the nested form
// ("from metabase.cards import metabase_get_card") are handled: the root package
// (the component before the first dot) is validated against the registry's Python
// package directories and each symbol is resolved against the whole package, not
// just the named sub-module. Parenthesised multi-line import lists and trailing
// "# noqa" comments are supported.
//
// Resolution is scoped to the matched package: symbols imported from a package
// that is NOT a registry package directory (e.g. "from numpy import array") are
// ignored, so the audit never produces false "missing" hits for third-party libs.
//
// Star imports ("from <pkg> import *") are NOT supported and yield no symbols —
// star imports are discouraged in the registry; see the .md notes.
//
// The pattern accepts either a parenthesised block (which may span newlines) or
// the rest of a single line as the import list.
var pyFromImportRe = regexp.MustCompile(`from\s+([\w.]+)\s+import\s+(\([\s\S]*?\)|[^\n]+)`)
func auditPyApp(appDir string, all map[string]auditFnMeta) []string { func auditPyApp(appDir string, all map[string]auditFnMeta) []string {
// Build nameid map for py functions. // Build package-dir → (nameid) map for py functions. The package directory
nameToID := make(map[string]string) // "metabase_auth" → "metabase_auth_py_infra" // is the first path component under python/functions/, which is NOT always the
// function's registry domain (e.g. metabase functions live in
// python/functions/metabase/ but have domain=infra), so it is derived from
// file_path rather than the domain field.
pkgFuncs := make(map[string]map[string]string) // "infra" → {"imap_connect": "imap_connect_py_infra"}
for _, m := range all { for _, m := range all {
if m.lang == "py" { if m.lang != "py" {
nameToID[m.name] = m.id continue
} }
pkg := pyPackageDir(m.filePath)
if pkg == "" {
continue
}
if pkgFuncs[pkg] == nil {
pkgFuncs[pkg] = make(map[string]string)
}
pkgFuncs[pkg][m.name] = m.id
} }
usedSet := make(map[string]bool) usedSet := make(map[string]bool)
@@ -453,23 +401,25 @@ func auditPyApp(appDir string, all map[string]auditFnMeta) []string {
if !strings.HasSuffix(path, ".py") { if !strings.HasSuffix(path, ".py") {
return nil return nil
} }
f, err := os.Open(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
return nil return nil
} }
defer f.Close() for _, m := range pyFromImportRe.FindAllStringSubmatch(string(data), -1) {
sc := bufio.NewScanner(f) // Root package = component before the first dot. Handles both the flat
for sc.Scan() { // ("metabase") and nested ("metabase.cards") import forms, plus relative
line := strings.TrimSpace(sc.Text()) // imports ("from .config import X" → root is "" → skipped).
if m := pyFromImportRe.FindStringSubmatch(line); m != nil { rootPkg := m[1]
// m[2] = "X, Y, Z" or "X" if i := strings.IndexByte(rootPkg, '.'); i >= 0 {
names := strings.Split(m[2], ",") rootPkg = rootPkg[:i]
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
} }
funcs, ok := pkgFuncs[rootPkg]
if !ok {
continue
}
for _, sym := range parsePyImportedSymbols(m[2]) {
if id, ok := funcs[sym]; ok {
usedSet[id] = true
} }
} }
} }
@@ -483,6 +433,57 @@ func auditPyApp(appDir string, all map[string]auditFnMeta) []string {
return used return used
} }
// pyPackageDir returns the top-level package directory of a registry Python
// function from its file_path. For "python/functions/metabase/cards.py" it
// returns "metabase". Returns "" when the path is not under python/functions/
// or has no package component.
func pyPackageDir(filePath string) string {
const prefix = "python/functions/"
fp := filepath.ToSlash(filePath)
if !strings.HasPrefix(fp, prefix) {
return ""
}
rest := fp[len(prefix):]
if i := strings.IndexByte(rest, '/'); i >= 0 {
return rest[:i]
}
return ""
}
// parsePyImportedSymbols extracts the imported symbol names from the right-hand
// side of a Python "from X import <rhs>" statement. It handles single-line lists,
// parenthesised multi-line lists, "# ..." line comments and "as alias" renames.
// A bare "*" (star import) yields no symbols.
func parsePyImportedSymbols(rhs string) []string {
// Drop trailing line comments so "import foo # noqa" and
// "import ( # noqa\n a,\n)" don't pollute symbol parsing.
var b strings.Builder
for _, ln := range strings.Split(rhs, "\n") {
if i := strings.IndexByte(ln, '#'); i >= 0 {
ln = ln[:i]
}
b.WriteString(ln)
b.WriteByte('\n')
}
s := strings.TrimSpace(b.String())
s = strings.TrimPrefix(s, "(")
s = strings.TrimSuffix(s, ")")
var out []string
for _, part := range strings.Split(s, ",") {
fields := strings.Fields(part) // splits "foo as bar" → ["foo","as","bar"]
if len(fields) == 0 {
continue
}
sym := strings.TrimSuffix(fields[0], ")") // safety for "a, b)" tails
if sym == "" || sym == "*" {
continue
}
out = append(out, sym)
}
return out
}
// snakeToPascal converts snake_case to PascalCase (Go exported name). // snakeToPascal converts snake_case to PascalCase (Go exported name).
// E.g. "sqlite_open" → "SQLiteOpen", "http_json_response" → "HTTPJSONResponse". // E.g. "sqlite_open" → "SQLiteOpen", "http_json_response" → "HTTPJSONResponse".
// Common abbreviations are uppercased in full. // Common abbreviations are uppercased in full.
@@ -537,34 +538,6 @@ var commonAbbrevs = map[string]string{
"io": "IO", "io": "IO",
"ok": "OK", "ok": "OK",
"ui": "UI", "ui": "UI",
// Issue 0057 — abbreviations verified consistent across the registry's own
// Go func names (each entry maps a real `func <Name>` deviation). These only
// improve the PascalCase fallback; the signature and the .go fallback remain
// the primary sources of truth. Deliberately NOT added because the registry
// itself is inconsistent for them (mapping would create more mismatches than
// it fixes): "cdp" (uses Cdp: CdpGetHTML, CdpNavigate — not CDP) and
// "pdf" (CdpPrintPDF vs PdfSimpleReport).
"ohlcv": "OHLCV",
"duckdb": "DuckDB",
"clickhouse": "ClickHouse",
"nordvpn": "NordVPN",
"sha256": "SHA256",
"md5": "MD5",
"ansi": "ANSI",
"cidr": "CIDR",
"aead": "AEAD",
"pty": "PTY",
"vps": "VPS",
"wg": "WG",
"vt": "VT",
"fft": "FFT",
"ema": "EMA",
"rsi": "RSI",
"sma": "SMA",
"vwap": "VWAP",
"ax": "AX",
"e2e": "E2E",
"urls": "URLs",
} }
// hasTSSources reports whether appDir contains any production .ts/.tsx files // hasTSSources reports whether appDir contains any production .ts/.tsx files
+22 -5
View File
@@ -3,7 +3,7 @@ name: audit_uses_functions
kind: function kind: function
lang: go lang: go
domain: infra domain: infra
version: "1.0.0" version: "1.1.0"
purity: impure purity: impure
signature: "func AuditUsesFunctions(registryRoot string) ([]UsesFunctionsAudit, error)" 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." 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."
@@ -23,6 +23,9 @@ tests:
- "missing function detected for Go app" - "missing function detected for Go app"
- "unused function detected for Go app" - "unused function detected for Go app"
- "missing dir returns entry with nil slices" - "missing dir returns entry with nil slices"
- "TestAuditUsesFunctions_DetectsNestedImport"
- "TestAuditUsesFunctions_NoFalsePositiveOnNested"
- "TestAuditUsesFunctions_StarImport"
test_file_path: "functions/infra/audit_uses_functions_test.go" test_file_path: "functions/infra/audit_uses_functions_test.go"
file_path: "functions/infra/audit_uses_functions.go" file_path: "functions/infra/audit_uses_functions.go"
--- ---
@@ -55,12 +58,26 @@ Si el nombre exportado real difiere de la convencion (ej. alias de paquete, re-e
## Heuristica Python ## 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). Busca sentencias `from <pkg> import X, Y` en los `.py` de la app y resuelve cada simbolo importado a su ID del registry:
1. **Paquete raiz**: toma el componente anterior al primer punto de `<pkg>` (`metabase.cards``metabase`). Solo procesa el import si ese paquete raiz es un directorio de paquete Python del registry (derivado de `file_path`, primer componente bajo `python/functions/`). Imports de librerias externas (`from numpy import array`) se ignoran, evitando falsos `missing`.
2. **Imports anidados**: `from metabase.cards import metabase_get_card` resuelve igual que `from metabase import metabase_get_card`. El simbolo se busca en TODO el paquete (`metabase`), no solo en el submodulo nombrado.
3. **Listas multilinea con parentesis**: `from datascience import (\n foo,\n bar,\n)` se parsea entero.
4. **Aliases y comentarios**: `from pkg import foo as bar # noqa` resuelve la funcion importada (`foo`); el alias local y el comentario se descartan.
El directorio de paquete se deriva de `file_path`, NO del campo `domain`: p.ej. las funciones `metabase` viven en `python/functions/metabase/` pero tienen `domain=infra`.
**No soportado** (fuera de alcance, issue 0056):
- `from <pkg> import *` (star import): se trata como vacio (no cuenta como uso). El registry desaconseja star imports.
- Carga dinamica con `importlib.util.spec_from_file_location(...)` o `import pkg` + `pkg.func()`: no son sentencias `from ... import` estaticas y no se detectan (causa el drift residual en apps como `osint_db`/`osint_web` que cargan funciones via wrapper dinamico).
## Notas ## Notas
- Read-only: no toca la BD ni archivos. - 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). - 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 positivos en `unused_in_app_md`: pueden ocurrir cuando la funcion del registry exporta un nombre no estandar (Go), o cuando una app Python la carga de forma dinamica (`importlib`). 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. - Falsos negativos (funcion usada no detectada): no ocurren para imports estaticos Python (`from pkg[.sub] import X`, incluido multilinea) ni para imports Go 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`).
## Capability growth log
- v1.1.0 (2026-06-30) — el parser Python detecta imports anidados (`from pkg.subpkg import X`) y listas multilinea con parentesis; resolucion validada contra el directorio de paquete del registry (derivado de `file_path`), eliminando falsos `unused` en apps que usaban esos patrones (issue 0056).
+171 -267
View File
@@ -57,6 +57,177 @@ INSERT INTO functions (id, name, domain, lang, file_path)
} }
} }
// insertTestFunctions appends extra functions to the test registry.db created by
// createTestRegistryDB. Used by the Python import tests, which need py functions
// whose file_path maps to a real package directory under python/functions/.
func insertTestFunctions(t *testing.T, root string, fns []struct {
id, name, domain, lang, filePath string
}) {
t.Helper()
db, err := sql.Open("sqlite3", filepath.Join(root, "registry.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
for _, f := range fns {
if _, err := db.Exec(
`INSERT INTO functions (id, name, domain, lang, file_path) VALUES (?,?,?,?,?)`,
f.id, f.name, f.domain, f.lang, f.filePath,
); err != nil {
t.Fatalf("insert fn %s: %v", f.id, err)
}
}
}
// writePyApp creates a Python app directory with a single source file.
func writePyApp(t *testing.T, root, dirPath, src string) {
t.Helper()
appDir := filepath.Join(root, dirPath)
if err := os.MkdirAll(appDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(appDir, "main.py"), []byte(src), 0644); err != nil {
t.Fatal(err)
}
}
// containsID reports whether ids contains target.
func containsID(ids []string, target string) bool {
for _, id := range ids {
if id == target {
return true
}
}
return false
}
// TestAuditUsesFunctions_DetectsNestedImport verifies that a nested import
// ("from metabase.cards import metabase_get_card") resolves to its function ID.
// The app declares no uses_functions, so the detected import surfaces as Missing.
func TestAuditUsesFunctions_DetectsNestedImport(t *testing.T) {
root := t.TempDir()
createTestRegistryDB(t, root, []struct {
id, lang, dirPath, usesFunctions string
}{
{"nestedapp_py_tools", "py", "apps/nestedapp", `[]`},
})
insertTestFunctions(t, root, []struct {
id, name, domain, lang, filePath string
}{
{"metabase_get_card_py_infra", "metabase_get_card", "infra", "py", "python/functions/metabase/cards.py"},
})
writePyApp(t, root, "apps/nestedapp", `import sys
from metabase.cards import metabase_get_card # noqa: E402
def run():
return metabase_get_card(1)
`)
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 !containsID(got.Missing, "metabase_get_card_py_infra") {
t.Errorf("nested import not detected: Missing = %v, want to contain metabase_get_card_py_infra", got.Missing)
}
if len(got.Unused) != 0 {
t.Errorf("Unused = %v, want []", got.Unused)
}
}
// TestAuditUsesFunctions_NoFalsePositiveOnNested verifies that when an app
// imports nested + multi-line parenthesised lists and declares them all in
// uses_functions, no function is reported as unused (the core regression fixed
// by this issue: false "unused" hits for nested/multi-line imports).
func TestAuditUsesFunctions_NoFalsePositiveOnNested(t *testing.T) {
root := t.TempDir()
createTestRegistryDB(t, root, []struct {
id, lang, dirPath, usesFunctions string
}{
{"nofp_py_tools", "py", "apps/nofp",
`["imap_connect_py_infra","smtp_send_py_infra","fetch_reddit_search_py_datascience","score_demand_signal_py_datascience"]`},
})
insertTestFunctions(t, root, []struct {
id, name, domain, lang, filePath string
}{
{"imap_connect_py_infra", "imap_connect", "infra", "py", "python/functions/infra/imap_connect.py"},
{"smtp_send_py_infra", "smtp_send", "infra", "py", "python/functions/infra/smtp_send.py"},
{"fetch_reddit_search_py_datascience", "fetch_reddit_search", "datascience", "py", "python/functions/datascience/fetch_reddit_search.py"},
{"score_demand_signal_py_datascience", "score_demand_signal", "datascience", "py", "python/functions/datascience/score_demand_signal.py"},
})
// Nested imports + a parenthesised multi-line list — both previously missed.
writePyApp(t, root, "apps/nofp", `import sys
from infra.imap_connect import imap_connect # noqa: E402
from infra.smtp_send import smtp_send, SMTPConfigPy # noqa: E402
from datascience import ( # noqa: E402
fetch_reddit_search,
score_demand_signal,
)
def run():
return imap_connect, smtp_send, fetch_reddit_search, score_demand_signal
`)
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) != 0 {
t.Errorf("false positive unused detected: Unused = %v, want []", got.Unused)
}
if len(got.Missing) != 0 {
t.Errorf("Missing = %v, want []", got.Missing)
}
}
// TestAuditUsesFunctions_StarImport documents that star imports
// ("from <pkg> import *") are NOT treated as using any function: a declared
// function not otherwise referenced is reported as unused.
func TestAuditUsesFunctions_StarImport(t *testing.T) {
root := t.TempDir()
createTestRegistryDB(t, root, []struct {
id, lang, dirPath, usesFunctions string
}{
{"starapp_py_tools", "py", "apps/starapp", `["filter_list_py_core"]`},
})
insertTestFunctions(t, root, []struct {
id, name, domain, lang, filePath string
}{
{"filter_list_py_core", "filter_list", "core", "py", "python/functions/core/core.py"},
})
writePyApp(t, root, "apps/starapp", `from core import *
def run():
return None
`)
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 !containsID(got.Unused, "filter_list_py_core") {
t.Errorf("star import should not count as usage: Unused = %v, want to contain filter_list_py_core", got.Unused)
}
if len(got.Missing) != 0 {
t.Errorf("Missing = %v, want []", got.Missing)
}
}
// TestAuditUsesFunctions_DetectsMissing verifies that a Go app that calls // TestAuditUsesFunctions_DetectsMissing verifies that a Go app that calls
// RandomHexID in its source but declares empty uses_functions gets // RandomHexID in its source but declares empty uses_functions gets
// random_hex_id_go_core reported as missing. // random_hex_id_go_core reported as missing.
@@ -148,273 +319,6 @@ func main() { fmt.Println("hello") }
}) })
} }
// TestSnakeToPascal_HandlesAbbreviations verifies the commonAbbrevs expansion
// (issue 0057, Fase 1). Each "want" is the exported Go symbol the registry
// actually uses for that snake_case name. It also pins the deliberate
// non-mappings (cdp, pdf): the registry's own convention is mixed-case there,
// so the abbreviation must NOT fire.
func TestSnakeToPascal_HandlesAbbreviations(t *testing.T) {
cases := []struct{ in, want string }{
// New abbreviations added by issue 0057 (verified against real func names).
{"fetch_ohlcv", "FetchOHLCV"},
{"normalize_ohlcv", "NormalizeOHLCV"},
{"duckdb_open", "DuckDBOpen"},
{"load_ohlcv_from_duckdb", "LoadOHLCVFromDuckDB"},
{"clickhouse_open", "ClickHouseOpen"},
{"nordvpn_container_run", "NordVPNContainerRun"},
{"parse_nordvpn_status", "ParseNordVPNStatus"},
{"hash_sha256", "HashSHA256"},
{"hash_md5", "HashMD5"},
{"strip_ansi", "StripANSI"},
{"parse_ip_cidr", "ParseIPCIDR"},
{"open_aead", "OpenAEAD"},
{"seal_aead", "SealAEAD"},
{"pty_capture_stream", "PTYCaptureStream"},
{"setup_vps_app", "SetupVPSApp"},
{"vps_setup_app", "VPSSetupApp"},
{"wg_keygen", "WGKeygen"},
{"wg_peer_add", "WGPeerAdd"},
{"vt_render", "VTRender"},
{"fft", "FFT"},
{"ema", "EMA"},
{"rsi", "RSI"},
{"sma", "SMA"},
{"vwap", "VWAP"},
{"cdp_get_ax_outline", "CdpGetAXOutline"},
{"audit_e2e_coverage", "AuditE2ECoverage"},
{"e2e_run_checks", "E2ERunChecks"},
{"extract_urls", "ExtractURLs"},
// Pre-existing abbreviations (regression guard — must keep working).
{"http_json_response", "HTTPJSONResponse"},
{"sqlite_open", "SQLiteOpen"},
{"random_hex_id", "RandomHexID"},
// Deliberate non-mappings: registry uses mixed-case (Cdp, Pdf) here, so
// the snake_case→Pascal conversion must leave them mixed-case. These are
// the cases the .go fallback (Fase 2) and the signature path cover.
{"cdp_get_html", "CdpGetHTML"},
{"cdp_navigate", "CdpNavigate"},
{"pdf_simple_report", "PdfSimpleReport"},
}
for _, c := range cases {
if got := snakeToPascal(c.in); got != c.want {
t.Errorf("snakeToPascal(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// goFallbackEnv builds a minimal registry.db + app on disk for the .go fallback
// test. The registry function gen_wails_crud_go_infra mimics wails_bind_crud:
// its signature omits the `func` keyword (so the signature regex misses) and its
// PascalCase("gen_wails_crud")="GenWailsCRUD" differs from the real exported
// symbol "GenerateWailsCRUD". The app calls the real symbol. When writeFnFile is
// true, the registry .go file exists and the fallback can recover the symbol.
func goFallbackEnv(t *testing.T, fnFilePath string, writeFnFile bool) UsesFunctionsAudit {
t.Helper()
root := t.TempDir()
dbPath := filepath.Join(root, "registry.db")
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
t.Fatal(err)
}
_, err = db.Exec(`
CREATE TABLE functions (id TEXT PRIMARY KEY, name TEXT, domain TEXT, lang TEXT, signature TEXT, file_path TEXT);
CREATE TABLE apps (id TEXT PRIMARY KEY, lang TEXT, dir_path TEXT, uses_functions TEXT DEFAULT '[]');
`)
if err != nil {
t.Fatal(err)
}
_, err = db.Exec(
`INSERT INTO functions (id,name,domain,lang,signature,file_path) VALUES (?,?,?,?,?,?)`,
"gen_wails_crud_go_infra", "gen_wails_crud", "infra", "go",
"GenerateWailsCRUD(spec WailsCRUDSpec) string", fnFilePath,
)
if err != nil {
t.Fatal(err)
}
_, err = db.Exec(
`INSERT INTO apps (id,lang,dir_path,uses_functions) VALUES (?,?,?,?)`,
"myapp_go_infra", "go", "apps/myapp", `["gen_wails_crud_go_infra"]`,
)
if err != nil {
t.Fatal(err)
}
db.Close()
if writeFnFile {
fnAbsDir := filepath.Join(root, filepath.Dir(fnFilePath))
if err := os.MkdirAll(fnAbsDir, 0755); err != nil {
t.Fatal(err)
}
src := "package infra\n\ntype WailsCRUDSpec struct{}\n\nfunc GenerateWailsCRUD(spec WailsCRUDSpec) string { return \"\" }\n"
if err := os.WriteFile(filepath.Join(root, fnFilePath), []byte(src), 0644); err != nil {
t.Fatal(err)
}
}
appDir := filepath.Join(root, "apps", "myapp")
if err := os.MkdirAll(appDir, 0755); err != nil {
t.Fatal(err)
}
appSrc := "package main\n\nimport (\n\t\"fmt\"\n\t\"fn-registry/functions/infra\"\n)\n\nfunc main() {\n\tfmt.Println(infra.GenerateWailsCRUD(infra.WailsCRUDSpec{}))\n}\n"
if err := os.WriteFile(filepath.Join(appDir, "main.go"), []byte(appSrc), 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))
}
return results[0]
}
// TestAuditUsesFunctions_GoFileFallback verifies the .go fallback (issue 0057,
// Fase 2): when neither the registry signature nor PascalCase(name) yields the
// real exported symbol, the auditor reads the function's .go file to recover it,
// so a genuinely-used function is not a false "unused". The error sub-case (file
// absent) shows the fallback degrades gracefully and the function is then
// correctly reported unused — proving the fallback is load-bearing.
func TestAuditUsesFunctions_GoFileFallback(t *testing.T) {
t.Run("golden: .go fallback recovers real symbol -> not unused", func(t *testing.T) {
got := goFallbackEnv(t, "functions/infra/gen_wails_crud.go", true)
if len(got.Unused) != 0 {
t.Errorf("Unused = %v, want [] (fallback should find GenerateWailsCRUD)", got.Unused)
}
if len(got.Missing) != 0 {
t.Errorf("Missing = %v, want []", got.Missing)
}
})
t.Run("error: missing .go file -> flagged unused, no crash", func(t *testing.T) {
got := goFallbackEnv(t, "functions/infra/gen_wails_crud.go", false)
if len(got.Unused) != 1 || got.Unused[0] != "gen_wails_crud_go_infra" {
t.Errorf("Unused = %v, want [gen_wails_crud_go_infra] (no fallback file to read)", got.Unused)
}
})
}
// TestAuditUsesFunctions_SharedGoFileNotMisattributed pins the regression caught
// during issue 0057 verification: several registry functions can share one .go
// file (the "TU adicional" pattern, e.g. cdp_new_tab living in cdp_list_tabs.go).
// Because they have valid signatures, the .go fallback must stay GATED OFF for
// them — otherwise the file's first exported func (here ListTabs) would be
// mis-attributed to a sibling (NewTab) and suppress a genuine "unused" finding.
// The app below uses only ListTabs; NewTab must remain flagged unused.
func TestAuditUsesFunctions_SharedGoFileNotMisattributed(t *testing.T) {
root := t.TempDir()
dbPath := filepath.Join(root, "registry.db")
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
t.Fatal(err)
}
_, err = db.Exec(`
CREATE TABLE functions (id TEXT PRIMARY KEY, name TEXT, domain TEXT, lang TEXT, signature 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,signature,file_path) VALUES
('list_tabs_go_browser','list_tabs','browser','go','func ListTabs() error','functions/browser/tabs.go'),
('new_tab_go_browser','new_tab','browser','go','func NewTab() error','functions/browser/tabs.go');
INSERT INTO apps (id,lang,dir_path,uses_functions) VALUES
('tabsapp_go_browser','go','apps/tabsapp','["list_tabs_go_browser","new_tab_go_browser"]');
`)
if err != nil {
t.Fatal(err)
}
db.Close()
// Shared registry .go file: ListTabs is the FIRST exported func.
fnDir := filepath.Join(root, "functions", "browser")
if err := os.MkdirAll(fnDir, 0755); err != nil {
t.Fatal(err)
}
tabsSrc := "package browser\n\nfunc ListTabs() error { return nil }\n\nfunc NewTab() error { return nil }\n"
if err := os.WriteFile(filepath.Join(fnDir, "tabs.go"), []byte(tabsSrc), 0644); err != nil {
t.Fatal(err)
}
// App calls only ListTabs, but declares both.
appDir := filepath.Join(root, "apps", "tabsapp")
if err := os.MkdirAll(appDir, 0755); err != nil {
t.Fatal(err)
}
appSrc := "package main\n\nimport (\n\t\"fmt\"\n\t\"fn-registry/functions/browser\"\n)\n\nfunc main() {\n\tfmt.Println(browser.ListTabs())\n}\n"
if err := os.WriteFile(filepath.Join(appDir, "main.go"), []byte(appSrc), 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] != "new_tab_go_browser" {
t.Errorf("Unused = %v, want [new_tab_go_browser] (sibling must NOT rescue via shared file)", got.Unused)
}
}
// TestGoRealExportedName verifies the .go symbol extractor: top-level exported
// funcs (plain and generic) are recovered, method receivers are skipped, the
// result is cached, and unreadable/empty paths return "" without error.
func TestGoRealExportedName(t *testing.T) {
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, "functions", "infra"), 0755); err != nil {
t.Fatal(err)
}
// File whose first exported func is preceded by an unexported func + a method.
src := "package infra\n\n" +
"import \"fmt\"\n\n" +
"func helper() {}\n\n" +
"type T struct{}\n\n" +
"func (t *T) Save() {}\n\n" +
"func GenerateWailsCRUD(spec int) string { fmt.Println(spec); return \"\" }\n\n" +
"func WailsStreamData[X any](xs []X) {}\n"
rel := "functions/infra/sample.go"
if err := os.WriteFile(filepath.Join(root, rel), []byte(src), 0644); err != nil {
t.Fatal(err)
}
cache := map[string]string{}
t.Run("golden: first top-level exported func (skips helper + method)", func(t *testing.T) {
if got := goRealExportedName(root, rel, cache); got != "GenerateWailsCRUD" {
t.Errorf("got %q, want GenerateWailsCRUD", got)
}
if cache[rel] != "GenerateWailsCRUD" {
t.Errorf("cache[%q] = %q, want GenerateWailsCRUD", rel, cache[rel])
}
})
t.Run("edge: generic func form func Name[T any](", func(t *testing.T) {
genRel := "functions/infra/gen.go"
genSrc := "package infra\n\nfunc WailsStreamData[X any](xs []X) {}\n"
if err := os.WriteFile(filepath.Join(root, genRel), []byte(genSrc), 0644); err != nil {
t.Fatal(err)
}
if got := goRealExportedName(root, genRel, cache); got != "WailsStreamData" {
t.Errorf("got %q, want WailsStreamData", got)
}
})
t.Run("error: missing file -> empty string, cached", func(t *testing.T) {
missRel := "functions/infra/does_not_exist.go"
if got := goRealExportedName(root, missRel, cache); got != "" {
t.Errorf("got %q, want empty for missing file", got)
}
if v, ok := cache[missRel]; !ok || v != "" {
t.Errorf("missing file should be cached as empty, got ok=%v v=%q", ok, v)
}
})
t.Run("error: empty file_path -> empty string", func(t *testing.T) {
if got := goRealExportedName(root, "", cache); got != "" {
t.Errorf("got %q, want empty for empty path", got)
}
})
}
// TestAuditUsesFunctions_MissingDir verifies that apps whose dir_path does not // TestAuditUsesFunctions_MissingDir verifies that apps whose dir_path does not
// exist on disk get an entry with nil Missing/Unused slices (cannot inspect). // exist on disk get an entry with nil Missing/Unused slices (cannot inspect).
func TestAuditUsesFunctions_MissingDir(t *testing.T) { func TestAuditUsesFunctions_MissingDir(t *testing.T) {