Files
fn_registry/functions/infra/audit_data_table_usage.go
T
egutierrez 7eb7b3d0c8 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>
2026-05-18 18:17:08 +02:00

464 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
// 25. 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]
}