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,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]
|
||||
}
|
||||
Reference in New Issue
Block a user