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 ;" 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] }