chore: snapshot WIP previo + flow 0008 + 7 sub-issues (0112-0119)
Snapshot de WIP acumulado de sesiones previas antes de merge wave 1 del flow 0008 (kanban_cpp + agent_runner_api + DoD schema). Incluye: - dev/flows/0008-kanban-cpp-and-agent-workflows.md - dev/issues/0112-0119*.md (7 sub-issues) - WIP previo en cmd/fn/doctor.go, registry/*, modules/, cpp/, etc. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,336 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AppDriftEntry describes an app and its module drift state relative to the registry.
|
||||
type AppDriftEntry struct {
|
||||
AppName string `json:"app_name"`
|
||||
AppID string `json:"app_id"`
|
||||
DirPath string `json:"dir_path"`
|
||||
BuildKind string `json:"build_kind"` // "windows-deployed" | "windows-build" | "linux-build" | ""
|
||||
BinaryPath string `json:"binary_path"` // path inspected (or modules_generated.cpp)
|
||||
LinkedModules map[string]string `json:"linked_modules"` // module name -> embedded version
|
||||
RegistryModules map[string]string `json:"registry_modules"` // module name -> registry version
|
||||
Stale []string `json:"stale"` // modules with drift (linked != registry)
|
||||
Status string `json:"status"` // "ok" | "drift" | "no-build"
|
||||
}
|
||||
|
||||
// AuditAppDrift scans all C++ apps in the registry, reads the module versions
|
||||
// embedded in their compiled artifacts, and compares them against the versions
|
||||
// declared in modules/*/module.md.
|
||||
//
|
||||
// Binary inspection priority (first found wins):
|
||||
// 1. windows-deployed: <windowsDeployRoot>/<name>/<name>.exe — PE binary search
|
||||
// 2. windows-build: cpp/build/windows/apps/<name>/<name>_modules_generated.cpp
|
||||
// 3. linux-build: cpp/build/linux/apps/<name>/<name>_modules_generated.cpp
|
||||
//
|
||||
// If windowsDeployRoot is empty, only cpp/build paths are checked.
|
||||
// registryRoot is the absolute path to the fn_registry root.
|
||||
func AuditAppDrift(registryRoot string, windowsDeployRoot string) ([]AppDriftEntry, error) {
|
||||
// 1. Read registry module versions from modules/*/module.md
|
||||
registryMods, err := readRegistryModules(registryRoot)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("audit_app_drift: read registry modules: %w", err)
|
||||
}
|
||||
|
||||
// 2. Find all C++ app.md files.
|
||||
appDirs, err := findAppDirs(registryRoot)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("audit_app_drift: find app dirs: %w", err)
|
||||
}
|
||||
|
||||
var result []AppDriftEntry
|
||||
|
||||
for _, dir := range appDirs {
|
||||
appMDPath := filepath.Join(dir, "app.md")
|
||||
meta, err := readCppAppMeta(appMDPath)
|
||||
if err != nil || meta.Lang != "cpp" {
|
||||
continue
|
||||
}
|
||||
|
||||
entry := AppDriftEntry{
|
||||
AppName: meta.Name,
|
||||
AppID: meta.ID,
|
||||
DirPath: meta.DirPath,
|
||||
LinkedModules: map[string]string{},
|
||||
RegistryModules: registryMods,
|
||||
}
|
||||
|
||||
// 3. Find artifact and extract linked module versions.
|
||||
kind, artifactPath, linked := resolveLinkedVersions(
|
||||
meta.Name, registryRoot, windowsDeployRoot,
|
||||
)
|
||||
entry.BuildKind = kind
|
||||
entry.BinaryPath = artifactPath
|
||||
entry.LinkedModules = linked
|
||||
|
||||
if kind == "" || linked == nil {
|
||||
entry.Status = "no-build"
|
||||
result = append(result, entry)
|
||||
continue
|
||||
}
|
||||
|
||||
// 4. Compute drift: only for modules that are both linked AND in registry.
|
||||
var stale []string
|
||||
for modName, linkedVer := range linked {
|
||||
regVer, inRegistry := registryMods[modName]
|
||||
if !inRegistry {
|
||||
continue
|
||||
}
|
||||
if linkedVer != regVer {
|
||||
stale = append(stale, modName)
|
||||
}
|
||||
}
|
||||
sort.Strings(stale)
|
||||
entry.Stale = stale
|
||||
|
||||
if len(stale) > 0 {
|
||||
entry.Status = "drift"
|
||||
} else {
|
||||
entry.Status = "ok"
|
||||
}
|
||||
|
||||
result = append(result, entry)
|
||||
}
|
||||
|
||||
sort.Slice(result, func(i, j int) bool { return result[i].AppID < result[j].AppID })
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// readRegistryModules reads modules/*/module.md and returns name -> version map.
|
||||
// Uses stdlib only: reads files line by line, extracts name/version with regex.
|
||||
func readRegistryModules(root string) (map[string]string, error) {
|
||||
modulesDir := filepath.Join(root, "modules")
|
||||
entries, err := os.ReadDir(modulesDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return map[string]string{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := map[string]string{}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
mdPath := filepath.Join(modulesDir, e.Name(), "module.md")
|
||||
name, version, err := parseModuleMD(mdPath)
|
||||
if err != nil || name == "" || version == "" {
|
||||
continue
|
||||
}
|
||||
out[name] = version
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
var (
|
||||
moduleNameRE = regexp.MustCompile(`(?m)^name:\s*(.+?)\s*$`)
|
||||
moduleVersionRE = regexp.MustCompile(`(?m)^version:\s*(.+?)\s*$`)
|
||||
)
|
||||
|
||||
// parseModuleMD extracts name and version from a module.md frontmatter using
|
||||
// simple line-by-line regex (no external YAML deps).
|
||||
func parseModuleMD(path string) (name, version string, err error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
// Restrict to frontmatter only (between --- markers).
|
||||
fm := extractFrontmatter(string(data))
|
||||
|
||||
if m := moduleNameRE.FindStringSubmatch(fm); len(m) > 1 {
|
||||
name = strings.TrimSpace(strings.Trim(m[1], `"`))
|
||||
}
|
||||
if m := moduleVersionRE.FindStringSubmatch(fm); len(m) > 1 {
|
||||
version = strings.TrimSpace(strings.Trim(m[1], `"`))
|
||||
}
|
||||
return name, version, nil
|
||||
}
|
||||
|
||||
// extractFrontmatter returns the YAML block between the first --- pair, or the
|
||||
// full content if no markers found.
|
||||
func extractFrontmatter(content string) string {
|
||||
if !strings.HasPrefix(content, "---") {
|
||||
return content
|
||||
}
|
||||
rest := content[3:]
|
||||
// Skip optional newline after opening ---
|
||||
if strings.HasPrefix(rest, "\n") {
|
||||
rest = rest[1:]
|
||||
}
|
||||
end := strings.Index(rest, "\n---")
|
||||
if end < 0 {
|
||||
return rest
|
||||
}
|
||||
return rest[:end]
|
||||
}
|
||||
|
||||
// cppAppMeta holds the fields we need from an app.md for C++ apps.
|
||||
type cppAppMeta struct {
|
||||
Name string
|
||||
ID string
|
||||
Lang string
|
||||
Domain string
|
||||
DirPath string
|
||||
}
|
||||
|
||||
var (
|
||||
appLangRE = regexp.MustCompile(`(?m)^lang:\s*(.+?)\s*$`)
|
||||
appNameRE = regexp.MustCompile(`(?m)^name:\s*(.+?)\s*$`)
|
||||
appDomainRE = regexp.MustCompile(`(?m)^domain:\s*(.+?)\s*$`)
|
||||
appDirPathRE = regexp.MustCompile(`(?m)^dir_path:\s*"?(.+?)"?\s*$`)
|
||||
)
|
||||
|
||||
// readCppAppMeta extracts name, lang, domain and dir_path from an app.md.
|
||||
func readCppAppMeta(path string) (cppAppMeta, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return cppAppMeta{}, err
|
||||
}
|
||||
fm := extractFrontmatter(string(data))
|
||||
|
||||
var meta cppAppMeta
|
||||
if m := appNameRE.FindStringSubmatch(fm); len(m) > 1 {
|
||||
meta.Name = strings.TrimSpace(strings.Trim(m[1], `"`))
|
||||
}
|
||||
if m := appLangRE.FindStringSubmatch(fm); len(m) > 1 {
|
||||
meta.Lang = strings.TrimSpace(strings.Trim(m[1], `"`))
|
||||
}
|
||||
if m := appDomainRE.FindStringSubmatch(fm); len(m) > 1 {
|
||||
meta.Domain = strings.TrimSpace(strings.Trim(m[1], `"`))
|
||||
}
|
||||
if m := appDirPathRE.FindStringSubmatch(fm); len(m) > 1 {
|
||||
meta.DirPath = strings.TrimSpace(strings.Trim(m[1], `"`))
|
||||
}
|
||||
|
||||
// Derive ID: name_lang_domain
|
||||
meta.ID = meta.Name
|
||||
if meta.Lang != "" {
|
||||
meta.ID += "_" + meta.Lang
|
||||
}
|
||||
if meta.Domain != "" {
|
||||
meta.ID += "_" + meta.Domain
|
||||
}
|
||||
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
// resolveLinkedVersions finds the best artifact for a given app and extracts
|
||||
// module name->version from it.
|
||||
//
|
||||
// Priority: windows-deployed > windows-build > linux-build.
|
||||
// Returns ("", "", nil) when no artifact is found.
|
||||
func resolveLinkedVersions(
|
||||
appName string,
|
||||
registryRoot string,
|
||||
windowsDeployRoot string,
|
||||
) (kind string, artifactPath string, linked map[string]string) {
|
||||
|
||||
// 1. Windows deployed exe.
|
||||
if windowsDeployRoot != "" {
|
||||
exePath := filepath.Join(windowsDeployRoot, appName, appName+".exe")
|
||||
if _, err := os.Stat(exePath); err == nil {
|
||||
if mods := extractModulesFromBinary(exePath); len(mods) > 0 {
|
||||
return "windows-deployed", exePath, mods
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Windows build modules_generated.cpp.
|
||||
winGenCPP := filepath.Join(
|
||||
registryRoot, "cpp", "build", "windows", "apps", appName,
|
||||
appName+"_modules_generated.cpp",
|
||||
)
|
||||
if _, err := os.Stat(winGenCPP); err == nil {
|
||||
if mods := extractModulesFromGeneratedCPP(winGenCPP); len(mods) > 0 {
|
||||
return "windows-build", winGenCPP, mods
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Linux build modules_generated.cpp.
|
||||
linuxGenCPP := filepath.Join(
|
||||
registryRoot, "cpp", "build", "linux", "apps", appName,
|
||||
appName+"_modules_generated.cpp",
|
||||
)
|
||||
if _, err := os.Stat(linuxGenCPP); err == nil {
|
||||
if mods := extractModulesFromGeneratedCPP(linuxGenCPP); len(mods) > 0 {
|
||||
return "linux-build", linuxGenCPP, mods
|
||||
}
|
||||
}
|
||||
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
// binaryModuleRE matches the pattern <module_name>\x00<semver>\x00 as it
|
||||
// appears in PE (Windows) binaries compiled from the generated ModuleInfo struct.
|
||||
// Group 1 = module name, group 2 = version string.
|
||||
var binaryModuleRE = regexp.MustCompile(`([a-z][a-z0-9_]{1,63})\x00(\d+\.\d+\.\d+)\x00`)
|
||||
|
||||
// extractModulesFromBinary scans a PE binary for the contiguous pattern
|
||||
// <module_name>\x00<semver>\x00 that the MSVC/MinGW compiler emits for the
|
||||
// ModuleInfo struct string literals. Returns nil when no matches are found.
|
||||
//
|
||||
// Note: ELF (Linux) binaries may not have this contiguous layout — use
|
||||
// extractModulesFromGeneratedCPP instead for Linux builds.
|
||||
func extractModulesFromBinary(path string) map[string]string {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := map[string]string{}
|
||||
for _, m := range binaryModuleRE.FindAllSubmatch(data, -1) {
|
||||
if len(m) < 3 {
|
||||
continue
|
||||
}
|
||||
name := string(m[1])
|
||||
ver := string(m[2])
|
||||
if _, seen := out[name]; !seen {
|
||||
out[name] = ver
|
||||
}
|
||||
}
|
||||
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// generatedModuleRE matches lines like: { "data_table", "1.5.0", "..." }
|
||||
var generatedModuleRE = regexp.MustCompile(`\{\s*"([a-z][a-z0-9_]*)"\s*,\s*"(\d+\.\d+\.\d+)"`)
|
||||
|
||||
// extractModulesFromGeneratedCPP parses a *_modules_generated.cpp file and
|
||||
// returns module name -> version. This is the authoritative source for Linux
|
||||
// builds and a reliable fallback for Windows builds.
|
||||
func extractModulesFromGeneratedCPP(path string) map[string]string {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := map[string]string{}
|
||||
for _, m := range generatedModuleRE.FindAllSubmatch(data, -1) {
|
||||
if len(m) < 3 {
|
||||
continue
|
||||
}
|
||||
name := string(m[1])
|
||||
ver := string(m[2])
|
||||
if _, seen := out[name]; !seen {
|
||||
out[name] = ver
|
||||
}
|
||||
}
|
||||
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
name: audit_app_drift
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func AuditAppDrift(registryRoot string, windowsDeployRoot string) ([]AppDriftEntry, error)"
|
||||
description: "Escanea todas las apps C++ del registry, extrae las versiones de modulos embebidas en sus artefactos compilados (exe Windows desplegado, modules_generated.cpp Windows/Linux) y las compara contra la version declarada en modules/*/module.md. Retorna una entrada por app con status ok|drift|no-build y la lista de modulos desactualizados."
|
||||
tags: [audit, drift, cpp, modules, doctor, registry]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: error_go_core
|
||||
imports:
|
||||
- fmt
|
||||
- os
|
||||
- path/filepath
|
||||
- regexp
|
||||
- sort
|
||||
- strings
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "functions/infra/audit_app_drift.go"
|
||||
params:
|
||||
- name: registryRoot
|
||||
desc: "Ruta absoluta a la raiz del fn_registry (ej. /home/lucas/fn_registry). Se buscan modules/*/module.md, apps/*/app.md, projects/*/apps/*/app.md y cpp/build/{linux,windows}/apps/<name>/."
|
||||
- name: windowsDeployRoot
|
||||
desc: "Ruta absoluta donde se despliegan los .exe de Windows (ej. /mnt/c/Users/lucas/Desktop/apps). Si vacio, se omite la inspeccion del binario desplegado y solo se usan los artefactos bajo cpp/build."
|
||||
output: "Slice de AppDriftEntry, uno por app C++ encontrada. Status: 'ok' (todas las versiones coinciden), 'drift' (al menos un modulo tiene version distinta), 'no-build' (no se encontro ningun artefacto compilado). Campo Stale lista los nombres de modulos con drift."
|
||||
---
|
||||
|
||||
# audit_app_drift
|
||||
|
||||
Detecta apps C++ cuyos binarios/artefactos enlazan una version de modulo inferior a la que declara el registry.
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
- Despues de bumpar la version de un modulo en `modules/*/module.md` — para saber que apps necesitan recompilarse.
|
||||
- Como check en `fn doctor cpp-apps` para detectar drift antes de un deploy.
|
||||
- En CI tras actualizar `fn_framework` o `fn_module_data_table` — lista apps con `status=drift`.
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
entries, err := infra.AuditAppDrift("/home/lucas/fn_registry", "/mnt/c/Users/lucas/Desktop/apps")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
switch e.Status {
|
||||
case "drift":
|
||||
fmt.Printf("STALE %s: modules %v (build: %s)\n", e.AppName, e.Stale, e.BuildKind)
|
||||
case "no-build":
|
||||
fmt.Printf("NOBLD %s: no compiled artifact found\n", e.AppName)
|
||||
case "ok":
|
||||
fmt.Printf("OK %s (%s)\n", e.AppName, e.BuildKind)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Salida tipica:
|
||||
|
||||
```
|
||||
STALE app_gestion: modules [data_table] (build: linux-build)
|
||||
OK chart_demo (windows-deployed)
|
||||
NOBLD primitives_gallery: no compiled artifact found
|
||||
```
|
||||
|
||||
## Prioridad de artefactos (por app)
|
||||
|
||||
1. `<windowsDeployRoot>/<name>/<name>.exe` — PE binario: busca patron `<module>\x00<version>\x00` directo en bytes.
|
||||
2. `cpp/build/windows/apps/<name>/<name>_modules_generated.cpp` — parsea literales `{ "module", "version", ... }`.
|
||||
3. `cpp/build/linux/apps/<name>/<name>_modules_generated.cpp` — mismo parser.
|
||||
|
||||
Si ninguno existe → `status = "no-build"`, `BuildKind = ""`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **PE vs ELF string layout**: en ELF (Linux) el compilador puede separar las cadenas del struct en distintas regiones del `.rodata`, por lo que la busqueda directa en el binario no es fiable. Por eso se prefiere el `_modules_generated.cpp` (generado por `codegen_app_modules.py` al compilar). El binario PE Windows si tiene el patron contiguo.
|
||||
- **Sin registry.db**: la funcion lee `module.md` directamente — no necesita la BD. Si el `fn index` no se ha ejecutado tras un bump de version, esta funcion igual detecta el drift porque lee la fuente de verdad (el `.md`).
|
||||
- **Apps sin `uses_modules`**: si una app C++ no declara `uses_modules` ni tiene `_modules_generated.cpp`, aparece con `status=no-build` aunque compile correctamente. Normal para apps que no usan modulos opcionales.
|
||||
- **Solo compara modulos del registry**: si el binario enlaza un modulo desconocido (que no tiene `module.md`), se ignora silenciosamente en el calculo de drift.
|
||||
- **windowsDeployRoot vacio**: si el usuario no tiene montado `/mnt/c/...` (no WSL2), pasar `""` para evitar `os.Stat` lento en rutas inexistentes.
|
||||
@@ -0,0 +1,463 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DataTableUsageEntry reporta el estado de la integracion data_table en una app.
|
||||
type DataTableUsageEntry struct {
|
||||
AppName string `json:"app_name"`
|
||||
AppID string `json:"app_id"`
|
||||
DirPath string `json:"dir_path"`
|
||||
DeclaresUse bool `json:"declares_use"` // uses_modules incluye data_table_cpp
|
||||
Findings []Finding `json:"findings"`
|
||||
Status string `json:"status"` // "ok" | "warn" | "n/a"
|
||||
}
|
||||
|
||||
// Finding describe un anti-patron detectado en la integracion data_table de una app.
|
||||
type Finding struct {
|
||||
Kind string `json:"kind"` // inline_begintable | state_not_persistent | no_child_host | no_event_sink | cmake_missing_link
|
||||
Severity string `json:"severity"` // "error" | "warn" | "info"
|
||||
File string `json:"file"`
|
||||
Line int `json:"line"` // 0 si no aplica
|
||||
Snippet string `json:"snippet"` // contexto opcional (hasta 120 chars)
|
||||
}
|
||||
|
||||
// regexes pre-compilados para los detectores
|
||||
var (
|
||||
// data_table::State declarada en una funcion (sin static ni thread_local).
|
||||
// Captura lineas con "data_table::State <ident>;" que no tengan static/thread_local antes.
|
||||
reStateDecl = regexp.MustCompile(`\bdata_table::State\s+\w+\s*;`)
|
||||
|
||||
// static o thread_local precediendo data_table::State en la misma linea.
|
||||
reStaticState = regexp.MustCompile(`(?:static|thread_local)\s+data_table::State`)
|
||||
|
||||
// Deteccion heuristica de inicio de funcion (cobertura parcial).
|
||||
reFuncOpen = regexp.MustCompile(`(?:static\s+)?(?:void|bool|int|ImVec2|auto|std::\w+)\s+\w+\s*\(`)
|
||||
|
||||
// ImGui::BeginTable inline (fuera de data_table::render).
|
||||
reBeginTable = regexp.MustCompile(`ImGui::BeginTable\s*\(`)
|
||||
|
||||
// data_table::render call.
|
||||
reRender = regexp.MustCompile(`data_table::render\s*\(`)
|
||||
|
||||
// BeginChild o Begin inmediatamente antes del render (heuristica 30 lineas).
|
||||
reBeginChildOrBegin = regexp.MustCompile(`ImGui::(?:BeginChild|Begin)\s*\(`)
|
||||
|
||||
// Presencia de event sink en el archivo.
|
||||
reEventSink = regexp.MustCompile(`(?:events_out|TableEvent|TableEventKind)`)
|
||||
)
|
||||
|
||||
// AuditDataTableUsage escanea todas las apps cpp del registry, detecta anti-patrones
|
||||
// en su uso de data_table::render, y devuelve entries por app.
|
||||
//
|
||||
// Detecta:
|
||||
// - inline_begintable: llamada directa a ImGui::BeginTable sin pasar por data_table::render.
|
||||
// - state_not_persistent: data_table::State sin static/thread_local (stack local).
|
||||
// - no_child_host: data_table::render sin BeginChild/Begin en las 30 lineas previas.
|
||||
// - no_event_sink: usa data_table pero no captura TableEvent/events_out (severity info).
|
||||
// - cmake_missing_link: uses_modules incluye data_table_cpp pero CMakeLists no enlaza fn_module_data_table.
|
||||
func AuditDataTableUsage(registryRoot string) ([]DataTableUsageEntry, error) {
|
||||
candidates, err := findAppDirs(registryRoot)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("audit_data_table_usage: find app dirs: %w", err)
|
||||
}
|
||||
|
||||
var results []DataTableUsageEntry
|
||||
|
||||
for _, dir := range candidates {
|
||||
appMDPath := filepath.Join(dir, "app.md")
|
||||
raw, err := os.ReadFile(appMDPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
fm, err := parseFrontmatterRaw(string(raw))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if fm.Lang != "cpp" {
|
||||
continue
|
||||
}
|
||||
|
||||
declaresUse := dtSliceContains(fm.UsesModules, "data_table_cpp")
|
||||
relDir, _ := filepath.Rel(registryRoot, dir)
|
||||
|
||||
entry := DataTableUsageEntry{
|
||||
AppName: fm.Name,
|
||||
AppID: buildID(fm),
|
||||
DirPath: relDir,
|
||||
DeclaresUse: declaresUse,
|
||||
Status: "n/a",
|
||||
}
|
||||
|
||||
if !declaresUse {
|
||||
results = append(results, entry)
|
||||
continue
|
||||
}
|
||||
|
||||
absDir := dir
|
||||
if !filepath.IsAbs(absDir) {
|
||||
absDir = filepath.Join(registryRoot, dir)
|
||||
}
|
||||
if _, err := os.Stat(absDir); os.IsNotExist(err) {
|
||||
entry.Status = "warn"
|
||||
entry.Findings = append(entry.Findings, Finding{
|
||||
Kind: "directory_missing",
|
||||
Severity: "warn",
|
||||
File: relDir,
|
||||
})
|
||||
results = append(results, entry)
|
||||
continue
|
||||
}
|
||||
|
||||
// 1. cmake_missing_link
|
||||
cmakePath := filepath.Join(absDir, "CMakeLists.txt")
|
||||
if cmakeFindings := auditCMakeLink(cmakePath, relDir); len(cmakeFindings) > 0 {
|
||||
entry.Findings = append(entry.Findings, cmakeFindings...)
|
||||
}
|
||||
|
||||
// Collect all .cpp and .h files (1 level + subdirs), excluding vendor/ build/ .git/
|
||||
sources, err := collectSourceFiles(absDir)
|
||||
if err != nil {
|
||||
entry.Status = "warn"
|
||||
results = append(results, entry)
|
||||
continue
|
||||
}
|
||||
|
||||
// 2–5. Per-file analysis
|
||||
for _, srcPath := range sources {
|
||||
data, err := os.ReadFile(srcPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
src := string(data)
|
||||
relSrc, _ := filepath.Rel(registryRoot, srcPath)
|
||||
|
||||
entry.Findings = append(entry.Findings, auditInlineBeginTable(src, relSrc)...)
|
||||
entry.Findings = append(entry.Findings, auditStateNotPersistent(src, relSrc)...)
|
||||
entry.Findings = append(entry.Findings, auditNoChildHost(src, relSrc)...)
|
||||
}
|
||||
|
||||
// 4. no_event_sink: check across ALL source files for event sink presence
|
||||
if noEventSinkFinding := auditNoEventSink(sources, relDir); noEventSinkFinding != nil {
|
||||
entry.Findings = append(entry.Findings, *noEventSinkFinding)
|
||||
}
|
||||
|
||||
// Derive status
|
||||
hasError := false
|
||||
hasWarn := false
|
||||
for _, f := range entry.Findings {
|
||||
switch f.Severity {
|
||||
case "error":
|
||||
hasError = true
|
||||
case "warn":
|
||||
hasWarn = true
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case hasError || hasWarn:
|
||||
entry.Status = "warn"
|
||||
default:
|
||||
entry.Status = "ok"
|
||||
}
|
||||
|
||||
results = append(results, entry)
|
||||
}
|
||||
|
||||
sort.Slice(results, func(i, j int) bool { return results[i].AppID < results[j].AppID })
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// auditCMakeLink verifica que CMakeLists.txt enlaza fn_module_data_table.
|
||||
func auditCMakeLink(cmakePath, relDir string) []Finding {
|
||||
data, err := os.ReadFile(cmakePath)
|
||||
if err != nil {
|
||||
// No CMakeLists at all — report as error since app declares data_table_cpp
|
||||
return []Finding{{
|
||||
Kind: "cmake_missing_link",
|
||||
Severity: "error",
|
||||
File: relDir,
|
||||
Snippet: "CMakeLists.txt not found",
|
||||
}}
|
||||
}
|
||||
if !strings.Contains(string(data), "fn_module_data_table") {
|
||||
return []Finding{{
|
||||
Kind: "cmake_missing_link",
|
||||
Severity: "error",
|
||||
File: strings.TrimSuffix(relDir, "/") + "/CMakeLists.txt",
|
||||
Snippet: "target_link_libraries missing fn_module_data_table",
|
||||
}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// auditInlineBeginTable detecta llamadas ImGui::BeginTable que no son parte de
|
||||
// data_table::render. Heuristica: si el archivo llama ImGui::BeginTable en una
|
||||
// linea que no es parte de la implementacion del modulo data_table.
|
||||
func auditInlineBeginTable(src, relFile string) []Finding {
|
||||
lines := strings.Split(src, "\n")
|
||||
var findings []Finding
|
||||
for i, line := range lines {
|
||||
if !reBeginTable.MatchString(line) {
|
||||
continue
|
||||
}
|
||||
// False-positive suppression: si el archivo ES parte del modulo data_table, ignorar
|
||||
if strings.Contains(relFile, "data_table") && strings.Contains(relFile, "modules") {
|
||||
continue
|
||||
}
|
||||
findings = append(findings, Finding{
|
||||
Kind: "inline_begintable",
|
||||
Severity: "warn",
|
||||
File: relFile,
|
||||
Line: i + 1,
|
||||
Snippet: truncateSnippet(strings.TrimSpace(line), 120),
|
||||
})
|
||||
}
|
||||
return findings
|
||||
}
|
||||
|
||||
// auditStateNotPersistent detecta data_table::State declarada en el body de una
|
||||
// funcion sin static ni thread_local. Heuristica: busca la linea con la declaracion
|
||||
// y retrocede hasta encontrar si hay un reFuncOpen antes que un closing brace a
|
||||
// nivel 0. Si la declaracion no tiene static/thread_local en la misma linea,
|
||||
// y esta dentro de un bloque de funcion (heuristica de depth de llaves), reporta.
|
||||
func auditStateNotPersistent(src, relFile string) []Finding {
|
||||
lines := strings.Split(src, "\n")
|
||||
var findings []Finding
|
||||
|
||||
for i, line := range lines {
|
||||
if !reStateDecl.MatchString(line) {
|
||||
continue
|
||||
}
|
||||
// Si la misma linea tiene static o thread_local -> OK
|
||||
if reStaticState.MatchString(line) {
|
||||
continue
|
||||
}
|
||||
// Check context: si pertenece a una struct/class member (no dentro de funcion)
|
||||
// Heuristica: mirar hacia atras si antes de depth>0 hay un { de funcion
|
||||
if isInsideFunctionBody(lines, i) {
|
||||
findings = append(findings, Finding{
|
||||
Kind: "state_not_persistent",
|
||||
Severity: "warn",
|
||||
File: relFile,
|
||||
Line: i + 1,
|
||||
Snippet: truncateSnippet(strings.TrimSpace(line), 120),
|
||||
})
|
||||
}
|
||||
}
|
||||
return findings
|
||||
}
|
||||
|
||||
// isInsideFunctionBody retorna true si la linea en `lineIdx` esta dentro del
|
||||
// cuerpo de una funcion (heuristica de balance de llaves retrocediendo).
|
||||
func isInsideFunctionBody(lines []string, lineIdx int) bool {
|
||||
depth := 0
|
||||
for i := lineIdx - 1; i >= 0; i-- {
|
||||
line := lines[i]
|
||||
for _, ch := range line {
|
||||
switch ch {
|
||||
case '}':
|
||||
depth++
|
||||
case '{':
|
||||
depth--
|
||||
}
|
||||
}
|
||||
if depth < 0 {
|
||||
// Encontramos la apertura del bloque que nos contiene
|
||||
// Si esa linea parece un function header, es funcion body
|
||||
return reFuncOpen.MatchString(line)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// auditNoChildHost detecta llamadas a data_table::render sin BeginChild/Begin
|
||||
// en las 30 lineas previas.
|
||||
func auditNoChildHost(src, relFile string) []Finding {
|
||||
lines := strings.Split(src, "\n")
|
||||
var findings []Finding
|
||||
for i, line := range lines {
|
||||
if !reRender.MatchString(line) {
|
||||
continue
|
||||
}
|
||||
// Skip comment lines (// ...) — they contain "data_table::render" as text, not calls.
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "//") {
|
||||
continue
|
||||
}
|
||||
// Look back up to 30 lines for BeginChild or Begin
|
||||
start := i - 30
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
found := false
|
||||
for j := start; j < i; j++ {
|
||||
if reBeginChildOrBegin.MatchString(lines[j]) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
findings = append(findings, Finding{
|
||||
Kind: "no_child_host",
|
||||
Severity: "warn",
|
||||
File: relFile,
|
||||
Line: i + 1,
|
||||
Snippet: truncateSnippet(strings.TrimSpace(line), 120),
|
||||
})
|
||||
}
|
||||
}
|
||||
return findings
|
||||
}
|
||||
|
||||
// auditNoEventSink verifica que al menos un archivo fuente use TableEvent/events_out.
|
||||
// Si ninguno lo hace, retorna un finding de severidad info.
|
||||
func auditNoEventSink(sources []string, relDir string) *Finding {
|
||||
for _, srcPath := range sources {
|
||||
data, err := os.ReadFile(srcPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if reEventSink.Match(data) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return &Finding{
|
||||
Kind: "no_event_sink",
|
||||
Severity: "info",
|
||||
File: relDir,
|
||||
Snippet: "no TableEvent / events_out found in any source file",
|
||||
}
|
||||
}
|
||||
|
||||
// collectSourceFiles retorna todos los .cpp y .h dentro de absDir,
|
||||
// excluyendo vendor/, build/, .git/, tests/, test/.
|
||||
func collectSourceFiles(absDir string) ([]string, error) {
|
||||
skipDirs := map[string]bool{
|
||||
"vendor": true,
|
||||
"build": true,
|
||||
".git": true,
|
||||
"tests": true,
|
||||
"test": true,
|
||||
}
|
||||
var files []string
|
||||
err := filepath.WalkDir(absDir, func(path string, d os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() {
|
||||
if skipDirs[d.Name()] {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
name := d.Name()
|
||||
if strings.HasSuffix(name, ".cpp") || strings.HasSuffix(name, ".h") {
|
||||
files = append(files, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return files, err
|
||||
}
|
||||
|
||||
// parseFrontmatterRaw extrae los campos minimos de un frontmatter YAML sin
|
||||
// dependencias externas (regex linea a linea).
|
||||
type rawFrontmatter struct {
|
||||
Name string
|
||||
Lang string
|
||||
Domain string
|
||||
UsesModules []string
|
||||
}
|
||||
|
||||
func parseFrontmatterRaw(content string) (rawFrontmatter, error) {
|
||||
if !strings.HasPrefix(content, "---") {
|
||||
return rawFrontmatter{}, fmt.Errorf("no frontmatter")
|
||||
}
|
||||
rest := content[4:]
|
||||
end := strings.Index(rest, "\n---")
|
||||
if end < 0 {
|
||||
return rawFrontmatter{}, fmt.Errorf("unclosed frontmatter")
|
||||
}
|
||||
body := rest[:end]
|
||||
|
||||
var fm rawFrontmatter
|
||||
inModules := false
|
||||
for _, line := range strings.Split(body, "\n") {
|
||||
// uses_modules: [data_table_cpp, framework_cpp] (inline array)
|
||||
if strings.HasPrefix(line, "uses_modules:") {
|
||||
inModules = true
|
||||
val := strings.TrimSpace(strings.TrimPrefix(line, "uses_modules:"))
|
||||
if strings.HasPrefix(val, "[") {
|
||||
val = strings.Trim(val, "[]")
|
||||
for _, item := range strings.Split(val, ",") {
|
||||
item = strings.TrimSpace(item)
|
||||
if item != "" {
|
||||
fm.UsesModules = append(fm.UsesModules, item)
|
||||
}
|
||||
}
|
||||
inModules = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Multi-line array entry: " - data_table_cpp"
|
||||
if inModules {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "-") {
|
||||
item := strings.TrimSpace(strings.TrimPrefix(trimmed, "-"))
|
||||
if item != "" {
|
||||
fm.UsesModules = append(fm.UsesModules, item)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// End of array
|
||||
if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") {
|
||||
inModules = false
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(line, "name:") {
|
||||
fm.Name = strings.TrimSpace(strings.TrimPrefix(line, "name:"))
|
||||
} else if strings.HasPrefix(line, "lang:") {
|
||||
fm.Lang = strings.TrimSpace(strings.TrimPrefix(line, "lang:"))
|
||||
fm.Lang = strings.Trim(fm.Lang, `"'`)
|
||||
} else if strings.HasPrefix(line, "domain:") {
|
||||
fm.Domain = strings.TrimSpace(strings.TrimPrefix(line, "domain:"))
|
||||
fm.Domain = strings.Trim(fm.Domain, `"'`)
|
||||
}
|
||||
}
|
||||
return fm, nil
|
||||
}
|
||||
|
||||
// buildID construye el ID del registry a partir del frontmatter.
|
||||
func buildID(fm rawFrontmatter) string {
|
||||
id := fm.Name
|
||||
if fm.Lang != "" {
|
||||
id += "_" + fm.Lang
|
||||
}
|
||||
if fm.Domain != "" {
|
||||
id += "_" + fm.Domain
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// dtSliceContains retorna true si s esta en slice.
|
||||
func dtSliceContains(slice []string, s string) bool {
|
||||
for _, v := range slice {
|
||||
if v == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// truncateSnippet recorta s a maxLen caracteres para uso en snippets de Finding.
|
||||
func truncateSnippet(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen]
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
name: audit_data_table_usage
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "AuditDataTableUsage(registryRoot string) ([]DataTableUsageEntry, error)"
|
||||
description: "Escanea apps C++ del registry que declaran uses_modules: [data_table_cpp] y detecta anti-patrones en su uso de data_table::render: ImGui::BeginTable inline, State stack-local (no persistente), render sin BeginChild host, event sink ignorado, y enlace cmake faltante."
|
||||
tags: [audit, data_table, cpp, lint, doctor, modules]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "functions/infra/audit_data_table_usage.go"
|
||||
params:
|
||||
- name: registryRoot
|
||||
desc: "Raiz absoluta del repositorio fn_registry. Se escanean apps/*, projects/*/apps/*. Solo procesa apps con lang: cpp y uses_modules incluye data_table_cpp."
|
||||
output: "Slice de DataTableUsageEntry, uno por app C++ con uses_modules: [data_table_cpp]. Status: ok (sin findings warn/error), warn (hay findings), n/a (no declara data_table_cpp)."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
import "fn-registry/functions/infra"
|
||||
|
||||
entries, err := infra.AuditDataTableUsage("/home/lucas/fn_registry")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.Status == "warn" {
|
||||
fmt.Printf("[%s] %s\n", e.AppID, e.DirPath)
|
||||
for _, f := range e.Findings {
|
||||
fmt.Printf(" %s (%s) %s:%d %s\n", f.Kind, f.Severity, f.File, f.Line, f.Snippet)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Expuesto via CLI (una vez integrado en `fn doctor`):
|
||||
|
||||
```bash
|
||||
fn doctor data-table # tabla legible
|
||||
fn doctor data-table --json # JSON para agentes
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
- Tras anadir `data_table_cpp` a un nuevo app, verifica que el patron canonico se sigue.
|
||||
- Como gate pre-merge cuando se toca código de integración de data_table.
|
||||
- Periodicamente para detectar apps que usan `ImGui::BeginTable` directamente en lugar de `data_table::render`.
|
||||
- Para auditar que el event sink (`TableEvent`/`events_out`) se consume donde se necesita interactividad.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `isInsideFunctionBody` es heuristica de balance de llaves: falsos positivos posibles con lambdas o structs anonimos. Ruido bajo en codigo real.
|
||||
- `no_child_host` mira 30 lineas hacia atras del `data_table::render(`. Si el BeginChild esta mas lejos (extraido en helper), se reportara falso positivo. Severidad `warn`, no `error`.
|
||||
- `inline_begintable` suprime findings cuando el archivo contiene `data_table` y `modules` en su path relativo (evita ruido del modulo mismo).
|
||||
- Apps cuyo directorio no existe en el PC local (sub-repos no clonados) se marcan `warn: directory_missing` en vez de analizarse. Esperado en entorno multi-PC.
|
||||
- `cmake_missing_link` usa `if(TARGET fn_module_data_table)` pattern (conditional link). La funcion detecta la presencia de la string `fn_module_data_table` en CMakeLists.txt, sea en `target_link_libraries` o en el guard.
|
||||
@@ -0,0 +1,131 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// ServiceSpecAudit reports drift between an app tagged `service` and the
|
||||
// `service:` frontmatter block populated by the indexer (issue 0105).
|
||||
type ServiceSpecAudit struct {
|
||||
AppID string `json:"app_id"`
|
||||
Name string `json:"name"`
|
||||
HasBlock bool `json:"has_block"`
|
||||
Runtime string `json:"runtime"`
|
||||
Port int `json:"port"`
|
||||
HealthPath string `json:"health_endpoint"`
|
||||
SystemdUnit string `json:"systemd_unit"`
|
||||
PCTargets []string `json:"pc_targets"`
|
||||
IsLocalOnly bool `json:"is_local_only"`
|
||||
RestartPolicy string `json:"restart_policy"`
|
||||
Issues []string `json:"issues"`
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
|
||||
// AuditServicesSpec lists every app with tag `service` and reports whether its
|
||||
// `service:` frontmatter is complete enough for downstream monitoring
|
||||
// (services_monitor app, issue 0106).
|
||||
//
|
||||
// Rules:
|
||||
// - block must exist (otherwise IsLocalOnly/runtime are all defaults).
|
||||
// - runtime is required (one of: systemd-user, systemd-system, docker-compose, stdio, manual).
|
||||
// - pc_targets must declare >= 1 pc_id.
|
||||
// - if runtime starts with `systemd-`, systemd_unit is required.
|
||||
// - if runtime in {systemd-*, docker-compose} and port > 0, health_endpoint is recommended (warning, not failure).
|
||||
func AuditServicesSpec(registryRoot string) ([]ServiceSpecAudit, error) {
|
||||
dbPath := registryRoot + "/registry.db"
|
||||
db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&mode=ro")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("audit_services_spec: open db: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
rows, err := db.Query(`
|
||||
SELECT id, name,
|
||||
COALESCE(service_runtime,''),
|
||||
COALESCE(service_port,0),
|
||||
COALESCE(service_health_endpoint,''),
|
||||
COALESCE(service_systemd_unit,''),
|
||||
COALESCE(service_restart_policy,''),
|
||||
COALESCE(service_is_local_only,0)
|
||||
FROM apps
|
||||
WHERE tags LIKE '%service%'
|
||||
ORDER BY id
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("audit_services_spec: query: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []ServiceSpecAudit
|
||||
for rows.Next() {
|
||||
var a ServiceSpecAudit
|
||||
var localOnly int
|
||||
if err := rows.Scan(
|
||||
&a.AppID, &a.Name,
|
||||
&a.Runtime, &a.Port, &a.HealthPath, &a.SystemdUnit, &a.RestartPolicy, &localOnly,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("audit_services_spec: scan: %w", err)
|
||||
}
|
||||
a.IsLocalOnly = localOnly != 0
|
||||
a.HasBlock = a.Runtime != "" || a.SystemdUnit != "" || a.Port != 0 || a.HealthPath != ""
|
||||
|
||||
// pc_targets from service_targets table.
|
||||
tRows, err := db.Query(
|
||||
"SELECT pc_id FROM service_targets WHERE app_id = ? ORDER BY pc_id",
|
||||
a.AppID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("audit_services_spec: service_targets query: %w", err)
|
||||
}
|
||||
for tRows.Next() {
|
||||
var pc string
|
||||
if err := tRows.Scan(&pc); err != nil {
|
||||
tRows.Close()
|
||||
return nil, err
|
||||
}
|
||||
a.PCTargets = append(a.PCTargets, pc)
|
||||
}
|
||||
tRows.Close()
|
||||
|
||||
// Validate.
|
||||
if !a.HasBlock {
|
||||
a.Issues = append(a.Issues, "missing service: block in app.md")
|
||||
}
|
||||
if a.Runtime == "" {
|
||||
a.Issues = append(a.Issues, "missing service.runtime")
|
||||
} else if !validRuntimes[a.Runtime] {
|
||||
a.Issues = append(a.Issues, "invalid service.runtime: "+a.Runtime)
|
||||
}
|
||||
if len(a.PCTargets) == 0 {
|
||||
a.Issues = append(a.Issues, "missing service.pc_targets (>= 1 required)")
|
||||
}
|
||||
if strings.HasPrefix(a.Runtime, "systemd-") && a.SystemdUnit == "" {
|
||||
a.Issues = append(a.Issues, "runtime systemd-* requires service.systemd_unit")
|
||||
}
|
||||
if a.RestartPolicy != "" && !validRestart[a.RestartPolicy] {
|
||||
a.Issues = append(a.Issues, "invalid service.restart_policy: "+a.RestartPolicy)
|
||||
}
|
||||
|
||||
a.OK = len(a.Issues) == 0
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
var validRuntimes = map[string]bool{
|
||||
"systemd-user": true,
|
||||
"systemd-system": true,
|
||||
"docker-compose": true,
|
||||
"stdio": true,
|
||||
"manual": true,
|
||||
}
|
||||
|
||||
var validRestart = map[string]bool{
|
||||
"always": true,
|
||||
"on-failure": true,
|
||||
"none": true,
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
name: audit_services_spec
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func AuditServicesSpec(registryRoot string) ([]ServiceSpecAudit, error)"
|
||||
description: "Audita apps con tag 'service': reporta drift entre el bloque service: del app.md y los datos requeridos por el monitor (port, health_endpoint, systemd_unit, pc_targets). Lee registry.db read-only via sql.Open. Issue 0105."
|
||||
tags: [audit, services, doctor, registry, sqlite, issue-0105]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: error_go_core
|
||||
imports:
|
||||
- database/sql
|
||||
- github.com/mattn/go-sqlite3
|
||||
tested: false
|
||||
file_path: functions/infra/audit_services_spec.go
|
||||
params:
|
||||
- name: registryRoot
|
||||
desc: "Ruta absoluta a la raiz del fn_registry (donde vive registry.db)."
|
||||
output: "Slice de ServiceSpecAudit (uno por app con tag service). OK=false si Issues no esta vacio."
|
||||
---
|
||||
|
||||
# audit_services_spec
|
||||
|
||||
Reporta apps con tag `service` cuya `service:` block esta incompleta.
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
- Subcomando `fn doctor services-spec` (este es su unico consumer hoy).
|
||||
- Antes de desplegar `services_monitor` (issue 0106) — si esta funcion devuelve `OK=false` para alguna app, el monitor no puede reconciliar estado.
|
||||
- En CI/cron para detectar regresiones cuando alguien crea app `tag: service` sin bloque.
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
audits, err := infra.AuditServicesSpec("/home/lucas/fn_registry")
|
||||
for _, a := range audits {
|
||||
if !a.OK {
|
||||
fmt.Println(a.AppID, "issues:", a.Issues)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Reglas que valida
|
||||
|
||||
- bloque presente (alguno de runtime/systemd_unit/port/health_endpoint != default).
|
||||
- `runtime` declarado y en allowlist (`systemd-user`, `systemd-system`, `docker-compose`, `stdio`, `manual`).
|
||||
- `pc_targets` con al menos 1 pc_id (cruzado contra tabla `service_targets`).
|
||||
- `runtime` empieza con `systemd-` ⇒ `systemd_unit` obligatorio.
|
||||
- `restart_policy` (si declarada) en `always`, `on-failure`, `none`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Lee `registry.db` en modo `?mode=ro`; si la base no existe o esta locked retorna error.
|
||||
- `service:` bloque parcial pasa el check `HasBlock=true` pero falla validaciones especificas — ver `Issues[]` para detalles.
|
||||
- No valida que el `port` este libre o el `systemd_unit` exista en disco; eso lo hace `services_status_go_infra` (runtime check).
|
||||
Reference in New Issue
Block a user