docs(flows): DoD obligatorio con user-facing surface + abrir issues 0100-0103 (taxonomia, frontmatter migration, dev_console, work dashboard)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,8 @@ import (
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -13,6 +15,9 @@ type ChromeLaunchOpts struct {
|
||||
// Port es el puerto de remote debugging. Por defecto 9222.
|
||||
Port int
|
||||
// UserDataDir es el directorio de perfil de Chrome. Por defecto /tmp/chrome-cdp-profile.
|
||||
// En WSL2 con chrome.exe, si el valor comienza con /tmp/ o /home/ se traduce
|
||||
// automaticamente a una ruta Windows via wslpath. Pasar una ruta Windows
|
||||
// (ej: C:\Users\...) la deja intacta.
|
||||
UserDataDir string
|
||||
// Headless activa el modo headless (--headless=new). Por defecto false.
|
||||
Headless bool
|
||||
@@ -22,6 +27,53 @@ type ChromeLaunchOpts struct {
|
||||
ExtraArgs []string
|
||||
}
|
||||
|
||||
// reWindowsPath coincide con rutas absolutas de Windows (C:\... D:\... etc.).
|
||||
var reWindowsPath = regexp.MustCompile(`(?i)^[A-Z]:\\`)
|
||||
|
||||
// isWSL2 devuelve true si el proceso corre dentro de WSL2.
|
||||
// Lee /proc/version y busca "microsoft" o "WSL" (case-insensitive).
|
||||
func isWSL2() bool {
|
||||
b, err := os.ReadFile("/proc/version")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
lower := strings.ToLower(string(b))
|
||||
return strings.Contains(lower, "microsoft") || strings.Contains(lower, "wsl")
|
||||
}
|
||||
|
||||
// isWindowsExe devuelve true si la ruta del ejecutable corresponde a un .exe
|
||||
// (chrome.exe en Windows via WSL2: puede ser /mnt/c/... o resolverse en PATH).
|
||||
func isWindowsExe(path string) bool {
|
||||
return strings.HasSuffix(strings.ToLower(path), ".exe")
|
||||
}
|
||||
|
||||
// translateUserDataDirForWindows convierte una ruta Linux a ruta Windows via wslpath.
|
||||
// Ejemplo: "/tmp/foo" -> "C:\Users\lucas\AppData\Local\Temp\foo" (depende del sistema).
|
||||
// Devuelve error si wslpath no esta disponible.
|
||||
func translateUserDataDirForWindows(linuxPath string) (string, error) {
|
||||
out, err := exec.Command("wslpath", "-w", linuxPath).Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("wslpath -w %q: %w", linuxPath, err)
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
// defaultWindowsUserDataDir devuelve la ruta Windows del perfil CDP por defecto,
|
||||
// usando la variable de entorno USERNAME de Windows si esta disponible.
|
||||
func defaultWindowsUserDataDir() (string, error) {
|
||||
// Intentar leer el home de Windows via wslpath del home de usuario
|
||||
// Si falla, usar C:\Users\Public\fn-chrome-cdp-profile como fallback.
|
||||
user := os.Getenv("USERNAME") // Windows user via WSL env passthrough
|
||||
if user == "" {
|
||||
user = os.Getenv("USER")
|
||||
}
|
||||
if user == "" {
|
||||
user = "Public"
|
||||
}
|
||||
linuxPath := fmt.Sprintf("/mnt/c/Users/%s/AppData/Local/fn-chrome-cdp-profile", user)
|
||||
return translateUserDataDirForWindows(linuxPath)
|
||||
}
|
||||
|
||||
// chromePaths lista los ejecutables de Chrome conocidos en WSL2/Linux.
|
||||
var chromePaths = []string{
|
||||
"chrome.exe",
|
||||
@@ -47,13 +99,13 @@ func findChrome() (string, error) {
|
||||
}
|
||||
|
||||
// waitCDPReady espera hasta que el puerto CDP responda conexiones TCP.
|
||||
// host puede estar vacio (usa "localhost").
|
||||
// host puede estar vacio (usa "127.0.0.1").
|
||||
func waitCDPReady(host string, port int, timeout time.Duration) error {
|
||||
if host == "" {
|
||||
host = "localhost"
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
deadline := time.Now().Add(timeout)
|
||||
addr := fmt.Sprintf("%s:%d", host, port)
|
||||
addr := net.JoinHostPort(host, fmt.Sprintf("%d", port))
|
||||
for time.Now().Before(deadline) {
|
||||
conn, err := net.DialTimeout("tcp", addr, 200*time.Millisecond)
|
||||
if err == nil {
|
||||
@@ -62,19 +114,22 @@ func waitCDPReady(host string, port int, timeout time.Duration) error {
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
return fmt.Errorf("chrome: puerto CDP %s:%d no disponible despues de %s", host, port, timeout)
|
||||
return fmt.Errorf("chrome: puerto CDP %s no disponible despues de %s", addr, timeout)
|
||||
}
|
||||
|
||||
// ChromeLaunch lanza Google Chrome con remote debugging habilitado en el puerto indicado.
|
||||
// Retorna el PID del proceso Chrome. Espera hasta 15s a que el puerto CDP este listo.
|
||||
// Busca chrome.exe en PATH (WSL2) o en rutas conocidas de Windows.
|
||||
//
|
||||
// WSL2 + chrome.exe: cuando se detecta WSL2 y el ejecutable es un .exe,
|
||||
// - El UserDataDir se traduce automaticamente a ruta Windows via wslpath
|
||||
// (si esta vacio o comienza con /tmp/ o /home/).
|
||||
// - Se inyecta --remote-debugging-address=0.0.0.0 para que Chrome sea
|
||||
// alcanzable desde WSL2 via 127.0.0.1 (el WSL networking reenvía localhost).
|
||||
func ChromeLaunch(opts ChromeLaunchOpts) (int, error) {
|
||||
if opts.Port == 0 {
|
||||
opts.Port = 9222
|
||||
}
|
||||
if opts.UserDataDir == "" {
|
||||
opts.UserDataDir = "/tmp/chrome-cdp-profile"
|
||||
}
|
||||
|
||||
chromePath := opts.ChromePath
|
||||
if chromePath == "" {
|
||||
@@ -85,9 +140,48 @@ func ChromeLaunch(opts ChromeLaunchOpts) (int, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Detectar si estamos en WSL2 lanzando un exe de Windows
|
||||
wsl2WindowsMode := isWSL2() && isWindowsExe(chromePath)
|
||||
|
||||
// Resolver UserDataDir
|
||||
userDataDir := opts.UserDataDir
|
||||
if wsl2WindowsMode {
|
||||
switch {
|
||||
case userDataDir == "" ||
|
||||
strings.HasPrefix(userDataDir, "/tmp/") ||
|
||||
strings.HasPrefix(userDataDir, "/home/"):
|
||||
// Traducir a ruta Windows
|
||||
if userDataDir == "" {
|
||||
var err error
|
||||
userDataDir, err = defaultWindowsUserDataDir()
|
||||
if err != nil {
|
||||
// Fallback seguro: usar una ruta Windows fija
|
||||
userDataDir = `C:\Users\Public\fn-chrome-cdp-profile`
|
||||
}
|
||||
} else {
|
||||
translated, err := translateUserDataDirForWindows(userDataDir)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("chrome: traducir user-data-dir para Windows: %w", err)
|
||||
}
|
||||
userDataDir = translated
|
||||
}
|
||||
case reWindowsPath.MatchString(userDataDir):
|
||||
// Ya es una ruta Windows absoluta (C:\...), dejar intacta
|
||||
default:
|
||||
// Ruta que no es ni /tmp/ ni /home/ ni Windows absoluta:
|
||||
// intentar traducir igualmente.
|
||||
translated, err := translateUserDataDirForWindows(userDataDir)
|
||||
if err == nil {
|
||||
userDataDir = translated
|
||||
}
|
||||
}
|
||||
} else if userDataDir == "" {
|
||||
userDataDir = "/tmp/chrome-cdp-profile"
|
||||
}
|
||||
|
||||
args := []string{
|
||||
fmt.Sprintf("--remote-debugging-port=%d", opts.Port),
|
||||
fmt.Sprintf("--user-data-dir=%s", opts.UserDataDir),
|
||||
fmt.Sprintf("--user-data-dir=%s", userDataDir),
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--disable-background-networking",
|
||||
@@ -101,12 +195,26 @@ func ChromeLaunch(opts ChromeLaunchOpts) (int, error) {
|
||||
"--disable-translate",
|
||||
"--metrics-recording-only",
|
||||
"--safebrowsing-disable-auto-update",
|
||||
"--remote-allow-origins=*",
|
||||
}
|
||||
|
||||
if opts.Headless {
|
||||
args = append(args, "--headless=new", "--disable-gpu")
|
||||
}
|
||||
|
||||
// En WSL2+Windows: inyectar --remote-debugging-address=0.0.0.0 si no esta ya presente
|
||||
hasBindAll := false
|
||||
for _, a := range opts.ExtraArgs {
|
||||
if a == "--remote-debugging-address=0.0.0.0" {
|
||||
hasBindAll = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if wsl2WindowsMode && !hasBindAll {
|
||||
args = append(args, "--remote-debugging-address=0.0.0.0")
|
||||
hasBindAll = true
|
||||
}
|
||||
|
||||
args = append(args, opts.ExtraArgs...)
|
||||
|
||||
cmd := exec.Command(chromePath, args...)
|
||||
@@ -120,20 +228,14 @@ func ChromeLaunch(opts ChromeLaunchOpts) (int, error) {
|
||||
|
||||
pid := cmd.Process.Pid
|
||||
|
||||
// Esperar a que el puerto CDP este listo
|
||||
// Si Chrome escucha en 0.0.0.0 (ej: WSL2 -> Windows), el caller se encarga del wait
|
||||
skipWait := false
|
||||
for _, a := range opts.ExtraArgs {
|
||||
if a == "--remote-debugging-address=0.0.0.0" {
|
||||
skipWait = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !skipWait {
|
||||
if err := waitCDPReady("localhost", opts.Port, 15*time.Second); err != nil {
|
||||
cmd.Process.Kill()
|
||||
return 0, err
|
||||
}
|
||||
// Esperar a que el puerto CDP este listo.
|
||||
// Siempre esperamos, incluyendo el caso WSL2+Windows donde Chrome escucha en
|
||||
// 0.0.0.0 — el WSL networking reenvía localhost:9222 → Windows:9222.
|
||||
// Usamos 127.0.0.1 explicitamente para evitar resolución IPv6 en algunos entornos.
|
||||
_ = hasBindAll // ya no se usa para skipWait
|
||||
if err := waitCDPReady("127.0.0.1", opts.Port, 15*time.Second); err != nil {
|
||||
cmd.Process.Kill()
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return pid, nil
|
||||
|
||||
@@ -3,23 +3,23 @@ name: chrome_launch
|
||||
kind: function
|
||||
lang: go
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
version: "1.1.0"
|
||||
purity: impure
|
||||
signature: "func ChromeLaunch(opts ChromeLaunchOpts) (int, error)"
|
||||
description: "Lanza Google Chrome con remote debugging habilitado en el puerto indicado. Busca chrome.exe en PATH (WSL2) o en rutas conocidas de Windows. Espera hasta 15s a que el puerto CDP este listo antes de retornar. Retorna el PID del proceso."
|
||||
tags: [chrome, cdp, browser, automation, wsl2, headless]
|
||||
description: "Lanza Google Chrome con remote debugging habilitado en el puerto indicado. Busca chrome.exe en PATH (WSL2) o en rutas conocidas de Windows. En WSL2+chrome.exe, traduce UserDataDir a ruta Windows via wslpath e inyecta --remote-debugging-address=0.0.0.0 automaticamente. Espera hasta 15s a que el puerto CDP este listo antes de retornar. Retorna el PID del proceso."
|
||||
tags: [chrome, cdp, browser, automation, wsl2, headless, navegator]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [fmt, net, os, os/exec, time]
|
||||
imports: [fmt, net, os, os/exec, regexp, strings, time]
|
||||
params:
|
||||
- name: opts
|
||||
desc: "opciones de lanzamiento: Port, UserDataDir, Headless"
|
||||
desc: "opciones de lanzamiento: Port (defecto 9222), UserDataDir (defecto /tmp/chrome-cdp-profile en Linux, C:\\Users\\<USER>\\AppData\\Local\\fn-chrome-cdp-profile en WSL2+exe), Headless, ChromePath, ExtraArgs"
|
||||
output: "int: PID del proceso Chrome lanzado"
|
||||
tested: true
|
||||
tests: ["TestFindChrome", "TestChromeLaunchAndConnect"]
|
||||
tests: ["TestIsWSL2", "TestTranslateUserDataDirForWindows", "TestIsWindowsExe", "TestFindChrome", "TestChromeLaunchAndConnect"]
|
||||
test_file_path: "functions/browser/chrome_launch_test.go"
|
||||
file_path: "functions/browser/chrome_launch.go"
|
||||
---
|
||||
@@ -27,10 +27,10 @@ file_path: "functions/browser/chrome_launch.go"
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
// Linux nativo (sin WSL2 o con Linux Chrome)
|
||||
pid, err := ChromeLaunch(ChromeLaunchOpts{
|
||||
Port: 9222,
|
||||
UserDataDir: "/tmp/chrome-cdp",
|
||||
Headless: true,
|
||||
Port: 9222,
|
||||
Headless: true,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
@@ -38,6 +38,32 @@ if err != nil {
|
||||
defer CdpClose(nil, pid)
|
||||
```
|
||||
|
||||
```go
|
||||
// WSL2 → chrome.exe Windows: cero configuracion, todo automatico
|
||||
// ChromeLaunch detecta WSL2+.exe, traduce user-data-dir y bind 0.0.0.0
|
||||
pid, err := ChromeLaunch(ChromeLaunchOpts{})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
// CDP listo en 127.0.0.1:9222 desde WSL2
|
||||
conn, err := CdpConnect(9222)
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites lanzar Chrome con CDP desde Go para automatizacion (scraping, tests, capturas). Usar antes de `CdpConnect` / `CdpNavigate` / `CdpScreenshot`. Funciona tanto en Linux nativo como en WSL2 apuntando al chrome.exe de Windows.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **WSL2 + chrome.exe**: la funcion detecta automaticamente WSL2 (`/proc/version` contiene "microsoft"/"WSL") y que el ejecutable es `.exe`. En ese caso:
|
||||
- `UserDataDir` vacio o con prefijo `/tmp/` o `/home/` se traduce via `wslpath -w` a ruta Windows. Por defecto: `C:\Users\<USER>\AppData\Local\fn-chrome-cdp-profile`.
|
||||
- Se inyecta `--remote-debugging-address=0.0.0.0` para que Chrome sea accesible desde WSL2 vía `127.0.0.1:<port>`.
|
||||
- `waitCDPReady` siempre espera usando `127.0.0.1` (WSL networking reenvía localhost → Windows).
|
||||
- **`wslpath` debe estar disponible**: se invoca como subproceso. Si falla, `ChromeLaunch` retorna error. `wslpath` es estándar en WSL2 desde Windows 10 1903+.
|
||||
- **Chrome no cierra solo**: el PID devuelto es el proceso Chrome. Usar `CdpClose(nil, pid)` o `os.FindProcess(pid).Kill()` para terminarlo.
|
||||
- **Puerto ocupado**: si el puerto ya está en uso por otra instancia de Chrome, `waitCDPReady` puede conectar al proceso previo. Usar puertos distintos por sesión.
|
||||
- **Headless en Windows via WSL2**: `--headless=new --disable-gpu` funciona bien con chrome.exe.
|
||||
|
||||
## Notas
|
||||
|
||||
Busca Chrome en este orden:
|
||||
@@ -49,3 +75,7 @@ Busca Chrome en este orden:
|
||||
Los flags aplicados desactivan funcionalidades de red y actualizacion en segundo plano para entornos de automatizacion. En modo headless se agrega `--headless=new --disable-gpu`.
|
||||
|
||||
El struct `ChromeLaunchOpts` se define en el mismo archivo.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.1.0 (2026-05-16) — auto-handle WSL2→Windows chrome.exe: translate user-data-dir via wslpath + inject --remote-debugging-address=0.0.0.0
|
||||
|
||||
@@ -2,11 +2,66 @@ package browser
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestIsWSL2 verifica que isWSL2 detecta el entorno correctamente leyendo /proc/version.
|
||||
func TestIsWSL2(t *testing.T) {
|
||||
b, err := os.ReadFile("/proc/version")
|
||||
if err != nil {
|
||||
t.Skip("/proc/version no disponible (no es Linux)")
|
||||
}
|
||||
lower := strings.ToLower(string(b))
|
||||
expectWSL2 := strings.Contains(lower, "microsoft") || strings.Contains(lower, "wsl")
|
||||
got := isWSL2()
|
||||
if got != expectWSL2 {
|
||||
t.Errorf("isWSL2() = %v, want %v (contenido: %q)", got, expectWSL2, string(b)[:min(120, len(b))])
|
||||
}
|
||||
t.Logf("isWSL2() = %v (entorno: %s)", got, string(b)[:min(80, len(b))])
|
||||
}
|
||||
|
||||
// TestTranslateUserDataDirForWindows verifica la traduccion de rutas Linux a Windows.
|
||||
// Solo corre si wslpath esta disponible (WSL2).
|
||||
func TestTranslateUserDataDirForWindows(t *testing.T) {
|
||||
if !isWSL2() {
|
||||
t.Skip("solo aplica en WSL2")
|
||||
}
|
||||
result, err := translateUserDataDirForWindows("/tmp/test-chrome-profile")
|
||||
if err != nil {
|
||||
t.Fatalf("translateUserDataDirForWindows: %v", err)
|
||||
}
|
||||
// El resultado debe contener backslash (ruta Windows) o empezar con [A-Z]:
|
||||
reWin := regexp.MustCompile(`(?i)^[A-Z]:\\|\\`)
|
||||
if !reWin.MatchString(result) {
|
||||
t.Errorf("resultado no parece ruta Windows: %q", result)
|
||||
}
|
||||
t.Logf("translateUserDataDirForWindows('/tmp/test-chrome-profile') = %q", result)
|
||||
}
|
||||
|
||||
// TestIsWindowsExe verifica que isWindowsExe detecta ejecutables .exe.
|
||||
func TestIsWindowsExe(t *testing.T) {
|
||||
cases := []struct {
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
{"/mnt/c/Program Files/Google/Chrome/Application/chrome.exe", true},
|
||||
{"chrome.exe", true},
|
||||
{"CHROME.EXE", true},
|
||||
{"/usr/bin/google-chrome", false},
|
||||
{"chromium", false},
|
||||
{"/mnt/c/Windows/System32/cmd.exe", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := isWindowsExe(c.path)
|
||||
if got != c.want {
|
||||
t.Errorf("isWindowsExe(%q) = %v, want %v", c.path, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFindChrome verifica que el ejecutable de Chrome es localizable.
|
||||
func TestFindChrome(t *testing.T) {
|
||||
path, err := findChrome()
|
||||
@@ -20,9 +75,10 @@ func TestFindChrome(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestChromeLaunchAndConnect lanza Chrome, conecta CDP, navega a about:blank y cierra.
|
||||
// Requiere CHROME_E2E=1 (integración real con Chrome).
|
||||
func TestChromeLaunchAndConnect(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skip: requiere Chrome real (use -short=false para ejecutar)")
|
||||
if os.Getenv("CHROME_E2E") != "1" {
|
||||
t.Skip("skip: requiere CHROME_E2E=1 y Chrome real")
|
||||
}
|
||||
|
||||
// Verificar que Chrome esta disponible
|
||||
@@ -67,9 +123,10 @@ func TestChromeLaunchAndConnect(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestCdpEvaluate ejecuta JS simple en Chrome y verifica el resultado.
|
||||
// Requiere CHROME_E2E=1.
|
||||
func TestCdpEvaluate(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skip: requiere Chrome real (use -short=false para ejecutar)")
|
||||
if os.Getenv("CHROME_E2E") != "1" {
|
||||
t.Skip("skip: requiere CHROME_E2E=1 y Chrome real")
|
||||
}
|
||||
|
||||
if _, err := findChrome(); err != nil {
|
||||
@@ -130,9 +187,10 @@ func TestCdpEvaluate(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestCdpGetHTML obtiene el HTML de about:blank y verifica que contiene elementos basicos.
|
||||
// Requiere CHROME_E2E=1.
|
||||
func TestCdpGetHTML(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skip: requiere Chrome real (use -short=false para ejecutar)")
|
||||
if os.Getenv("CHROME_E2E") != "1" {
|
||||
t.Skip("skip: requiere CHROME_E2E=1 y Chrome real")
|
||||
}
|
||||
|
||||
if _, err := findChrome(); err != nil {
|
||||
@@ -178,9 +236,10 @@ func TestCdpGetHTML(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestCdpScreenshot toma un screenshot de about:blank y verifica que se crea el archivo PNG.
|
||||
// Requiere CHROME_E2E=1.
|
||||
func TestCdpScreenshot(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skip: requiere Chrome real (use -short=false para ejecutar)")
|
||||
if os.Getenv("CHROME_E2E") != "1" {
|
||||
t.Skip("skip: requiere CHROME_E2E=1 y Chrome real")
|
||||
}
|
||||
|
||||
if _, err := findChrome(); err != nil {
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// ModuleDriftCheck describes per-app drift between app.md uses_modules and
|
||||
// CMakeLists.txt fn_module_* link calls.
|
||||
type ModuleDriftCheck struct {
|
||||
AppID string `json:"app_id"`
|
||||
AppMD string `json:"app_md"`
|
||||
CMakeLists string `json:"cmake_lists"`
|
||||
Declared []string `json:"declared"` // module IDs from uses_modules
|
||||
Linked []string `json:"linked"` // module names from fn_module_<name>
|
||||
MissingLinks []string `json:"missing_links"` // declared but not linked
|
||||
ExtraLinks []string `json:"extra_links"` // linked but not declared
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
|
||||
var (
|
||||
cmakeLinkRE = regexp.MustCompile(`\bfn_module_([a-z0-9_]+)\b`)
|
||||
)
|
||||
|
||||
// AuditModulesDrift scans apps/*/app.md, projects/*/apps/*/app.md, cpp/apps/*/app.md
|
||||
// and compares uses_modules in the frontmatter against fn_module_<name> link calls
|
||||
// in the adjacent CMakeLists.txt.
|
||||
//
|
||||
// An app is OK when:
|
||||
// - It has no CMakeLists.txt (non-C++ app) — drift check N/A; skipped silently.
|
||||
// - declared (modulo `_<lang>` suffix) == linked.
|
||||
func AuditModulesDrift(root string) ([]ModuleDriftCheck, error) {
|
||||
candidates, err := findAppDirs(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result []ModuleDriftCheck
|
||||
for _, dir := range candidates {
|
||||
appMD := filepath.Join(dir, "app.md")
|
||||
cmakeLists := filepath.Join(dir, "CMakeLists.txt")
|
||||
|
||||
if _, err := os.Stat(cmakeLists); err != nil {
|
||||
// Non-C++ app or app without CMakeLists. Skip drift check.
|
||||
continue
|
||||
}
|
||||
|
||||
declared, appID, err := readUsesModules(appMD)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
linked, err := readLinkedModules(cmakeLists)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Normalize declared (module IDs like "data_table_cpp") to module names
|
||||
// for comparison with link target names ("fn_module_data_table" -> "data_table").
|
||||
declaredNames := make([]string, 0, len(declared))
|
||||
for _, d := range declared {
|
||||
declaredNames = append(declaredNames, stripLangSuffix(d))
|
||||
}
|
||||
|
||||
missing := diffStrings(declaredNames, linked)
|
||||
extra := diffStrings(linked, declaredNames)
|
||||
|
||||
relMD, _ := filepath.Rel(root, appMD)
|
||||
relCM, _ := filepath.Rel(root, cmakeLists)
|
||||
|
||||
result = append(result, ModuleDriftCheck{
|
||||
AppID: appID,
|
||||
AppMD: relMD,
|
||||
CMakeLists: relCM,
|
||||
Declared: declaredNames,
|
||||
Linked: linked,
|
||||
MissingLinks: missing,
|
||||
ExtraLinks: extra,
|
||||
OK: len(missing) == 0 && len(extra) == 0,
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(result, func(i, j int) bool { return result[i].AppID < result[j].AppID })
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// findAppDirs returns directories that contain an app.md file:
|
||||
// - <root>/apps/*/
|
||||
// - <root>/projects/*/apps/*/
|
||||
func findAppDirs(root string) ([]string, error) {
|
||||
var dirs []string
|
||||
|
||||
// <root>/apps/*/
|
||||
appsRoot := filepath.Join(root, "apps")
|
||||
if entries, err := os.ReadDir(appsRoot); err == nil {
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
candidate := filepath.Join(appsRoot, e.Name())
|
||||
if _, err := os.Stat(filepath.Join(candidate, "app.md")); err == nil {
|
||||
dirs = append(dirs, candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// <root>/projects/*/apps/*/
|
||||
projectsRoot := filepath.Join(root, "projects")
|
||||
if projEntries, err := os.ReadDir(projectsRoot); err == nil {
|
||||
for _, pe := range projEntries {
|
||||
if !pe.IsDir() {
|
||||
continue
|
||||
}
|
||||
projAppsDir := filepath.Join(projectsRoot, pe.Name(), "apps")
|
||||
if appEntries, err := os.ReadDir(projAppsDir); err == nil {
|
||||
for _, ae := range appEntries {
|
||||
if !ae.IsDir() {
|
||||
continue
|
||||
}
|
||||
candidate := filepath.Join(projAppsDir, ae.Name())
|
||||
if _, err := os.Stat(filepath.Join(candidate, "app.md")); err == nil {
|
||||
dirs = append(dirs, candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dirs, nil
|
||||
}
|
||||
|
||||
type appFrontmatter struct {
|
||||
Name string `yaml:"name"`
|
||||
Lang string `yaml:"lang"`
|
||||
Domain string `yaml:"domain"`
|
||||
UsesModules []string `yaml:"uses_modules"`
|
||||
}
|
||||
|
||||
func readUsesModules(path string) (modules []string, appID string, err error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
// Extract YAML frontmatter between leading "---" markers.
|
||||
if !strings.HasPrefix(string(data), "---") {
|
||||
return nil, "", fmt.Errorf("missing frontmatter in %s", path)
|
||||
}
|
||||
rest := string(data)[4:]
|
||||
end := strings.Index(rest, "\n---")
|
||||
if end < 0 {
|
||||
return nil, "", fmt.Errorf("missing closing --- in %s", path)
|
||||
}
|
||||
fm := rest[:end]
|
||||
|
||||
var raw appFrontmatter
|
||||
if err := yaml.Unmarshal([]byte(fm), &raw); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
if raw.Name == "" {
|
||||
return nil, "", fmt.Errorf("no name in %s", path)
|
||||
}
|
||||
id := raw.Name
|
||||
if raw.Lang != "" {
|
||||
id += "_" + raw.Lang
|
||||
}
|
||||
if raw.Domain != "" {
|
||||
id += "_" + raw.Domain
|
||||
}
|
||||
return raw.UsesModules, id, nil
|
||||
}
|
||||
|
||||
func readLinkedModules(cmakePath string) ([]string, error) {
|
||||
data, err := os.ReadFile(cmakePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
matches := cmakeLinkRE.FindAllStringSubmatch(string(data), -1)
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, m := range matches {
|
||||
if len(m) < 2 {
|
||||
continue
|
||||
}
|
||||
if !seen[m[1]] {
|
||||
seen[m[1]] = true
|
||||
out = append(out, m[1])
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// stripLangSuffix removes a trailing _<lang> suffix for matching purposes.
|
||||
// "data_table_cpp" -> "data_table".
|
||||
func stripLangSuffix(id string) string {
|
||||
for _, suf := range []string{"_cpp", "_py", "_ts", "_bash", "_go"} {
|
||||
if strings.HasSuffix(id, suf) {
|
||||
return id[:len(id)-len(suf)]
|
||||
}
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// diffStrings returns elements in a that are not in b.
|
||||
func diffStrings(a, b []string) []string {
|
||||
bset := map[string]bool{}
|
||||
for _, x := range b {
|
||||
bset[x] = true
|
||||
}
|
||||
var out []string
|
||||
for _, x := range a {
|
||||
if !bset[x] {
|
||||
out = append(out, x)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
name: audit_modules_drift
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "AuditModulesDrift(root string) ([]ModuleDriftCheck, error)"
|
||||
description: "Detecta drift entre app.md uses_modules y CMakeLists.txt fn_module_<name> link calls. Para cada app C++ con CMakeLists.txt: parsea uses_modules + regex sobre target_link_libraries. Devuelve por-app: declared/linked/missing/extra/OK."
|
||||
tags: [audit, modules, cmake, drift, doctor, cpp]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports:
|
||||
- gopkg.in/yaml.v3
|
||||
file_path: "functions/infra/audit_modules_drift.go"
|
||||
params:
|
||||
- name: root
|
||||
desc: "Raiz del repositorio fn_registry. Se escanean apps/*, projects/*/apps/*."
|
||||
output: "Slice de ModuleDriftCheck (uno por app C++ con CMakeLists.txt). Apps sin CMakeLists son saltadas."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
import "fn-registry/functions/infra"
|
||||
|
||||
checks, err := infra.AuditModulesDrift("/home/lucas/fn_registry")
|
||||
if err != nil { panic(err) }
|
||||
for _, c := range checks {
|
||||
if !c.OK {
|
||||
fmt.Printf("DRIFT %s: missing=%v extra=%v\n", c.AppID, c.MissingLinks, c.ExtraLinks)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Tambien expuesto via CLI:
|
||||
|
||||
```bash
|
||||
fn doctor modules # tabla legible
|
||||
fn doctor modules --json # JSON para agentes
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Tras anadir/quitar un modulo a la app:
|
||||
- Verifica que el `uses_modules` del `app.md` y `target_link_libraries(... PRIVATE fn_module_*)` del CMakeLists.txt coinciden.
|
||||
- Tras renombrar un modulo, detecta apps que quedaron con la version antigua.
|
||||
- Como gate en `/full-git-push` antes de mergear cambios de modulos.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Apps sin `CMakeLists.txt` (Python, bash, etc.) se saltan — el drift check no aplica.
|
||||
- Modulos IDs en `uses_modules` llevan sufijo `_<lang>` (ej. `data_table_cpp`); los link targets son `fn_module_<name>` (sin sufijo). La funcion strippa el sufijo antes de comparar.
|
||||
- Regex acepta `fn_module_<name>` en cualquier parte del CMakeLists — comentarios incluidos. Si un comentario referencia un modulo no usado, se reporta como `extra_links` (falso positivo aceptable).
|
||||
Reference in New Issue
Block a user