Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a7a874a76 | |||
| 5501507588 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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 name→id map for py functions.
|
// Build package-dir → (name → id) 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
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user