From 78d955fd726cd659878620a6f56554e97ad2e880 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Mon, 18 May 2026 18:12:49 +0200 Subject: [PATCH] feat(infra): audit_dod_schema + fn doctor dod (issue 0114) Adds AuditDodSchema(issuesDir, flowsDir) which scans dev/issues/ and dev/flows/ frontmatter for the new optional dod_evidence_schema: block. Validates id uniqueness, kind in {screenshot,log,url,cmd}, expected non-empty and required bool (default true). Tolerant to malformed YAML and missing block. Wires it into fn doctor dod with human-readable caveman output and --json support. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/fn/doctor.go | 42 ++++++ functions/infra/audit_dod_schema.go | 211 ++++++++++++++++++++++++++++ functions/infra/audit_dod_schema.md | 66 +++++++++ 3 files changed, 319 insertions(+) create mode 100644 functions/infra/audit_dod_schema.go create mode 100644 functions/infra/audit_dod_schema.md diff --git a/cmd/fn/doctor.go b/cmd/fn/doctor.go index 0c24bfc7..93683e4e 100644 --- a/cmd/fn/doctor.go +++ b/cmd/fn/doctor.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "os" + "path/filepath" "strings" "text/tabwriter" @@ -63,6 +64,8 @@ func cmdDoctor(args []string) { doctorAppLocation(r, jsonOut) case "modules": doctorModules(r, jsonOut) + case "dod": + doctorDod(r, jsonOut) default: fmt.Fprintf(os.Stderr, "unknown doctor subcommand: %s\n", sub) doctorUsage() @@ -90,6 +93,7 @@ Subcommands: capabilities Drift entre docs/capabilities/INDEX.md, tags de funciones, y paginas .md (issue 0086) app-location Detecta artefactos (apps/analysis) en carpetas de lenguaje (cpp/apps/, etc.) - issue 0096 modules Drift entre uses_modules (app.md) y fn_module_ link calls (CMakeLists.txt) - issue 0097 + dod Audita bloque dod_evidence_schema en dev/issues/ y dev/flows/ (issue 0114) Flags: --json Salida JSON (para scripting/agentes) @@ -588,3 +592,41 @@ func doctorModules(root string, jsonOut bool) { fmt.Println("Fix: align uses_modules in app.md with target_link_libraries(fn_module_*) in CMakeLists.txt.") } } + +func doctorDod(root string, jsonOut bool) { + issuesDir := filepath.Join(root, "dev", "issues") + flowsDir := filepath.Join(root, "dev", "flows") + report, err := infra.AuditDodSchema(issuesDir, flowsDir) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + if jsonOut { + emit(report) + return + } + fmt.Println("=== DoD Schema Audit ===") + fmt.Printf("files scanned: %d\n", report.TotalFiles) + fmt.Printf("with schema: %d\n", report.FilesWithItems) + fmt.Printf("total items: %d\n", report.TotalItems) + fmt.Printf("invalid items: %d\n", report.InvalidItems) + if report.InvalidItems == 0 { + fmt.Println("\nAll DoD schemas valid.") + return + } + fmt.Println() + rel := func(p string) string { + if r, err := filepath.Rel(root, p); err == nil { + return r + } + return p + } + for _, f := range report.Files { + if len(f.Errors) == 0 { + continue + } + for _, e := range f.Errors { + fmt.Printf("%s : %s\n", rel(f.Path), e) + } + } +} diff --git a/functions/infra/audit_dod_schema.go b/functions/infra/audit_dod_schema.go new file mode 100644 index 00000000..61440c08 --- /dev/null +++ b/functions/infra/audit_dod_schema.go @@ -0,0 +1,211 @@ +package infra + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "gopkg.in/yaml.v3" +) + +// DodSchemaItem represents one declared evidence item in a DoD schema block. +type DodSchemaItem struct { + ID string `yaml:"id" json:"id"` + Kind string `yaml:"kind" json:"kind"` // screenshot|log|url|cmd + Expected string `yaml:"expected" json:"expected"` // free text + Required bool `yaml:"required" json:"required"` // default true if missing +} + +// DodSchemaIssue represents one issue/flow file scanned and its parsed schema. +type DodSchemaIssue struct { + Path string `json:"path"` + Type string `json:"type"` // "issue" | "flow" + Items []DodSchemaItem `json:"items"` // parsed items (may be empty) + Errors []string `json:"errors"` // per-file validation errors +} + +// DodSchemaReport aggregates the scan of dev/issues/ and dev/flows/. +type DodSchemaReport struct { + Files []DodSchemaIssue `json:"files"` + TotalFiles int `json:"total_files"` + FilesWithItems int `json:"files_with_items"` + TotalItems int `json:"total_items"` + InvalidItems int `json:"invalid_items"` +} + +// dodValidKinds is the closed set of allowed evidence kinds. +var dodValidKinds = map[string]struct{}{ + "screenshot": {}, + "log": {}, + "url": {}, + "cmd": {}, +} + +// dodRawFrontmatter is used for YAML unmarshal — we keep `required` as a +// pointer so we can distinguish "missing" (defaults to true) from "false". +type dodRawItem struct { + ID string `yaml:"id"` + Kind string `yaml:"kind"` + Expected string `yaml:"expected"` + Required *bool `yaml:"required"` +} + +type dodRawFrontmatter struct { + DodEvidenceSchema []dodRawItem `yaml:"dod_evidence_schema"` +} + +// AuditDodSchema scans dev/issues/ (recursively, incl. completed/) and +// dev/flows/ (recursively, incl. completed/) under `issuesDir` and `flowsDir`, +// parses the `dod_evidence_schema:` block from each `.md` frontmatter, and +// returns a structured report. Read-only — does not write anything. +// +// Validations per item: +// - id non-empty and unique within the file +// - kind in {screenshot, log, url, cmd} +// - expected non-empty +// - required defaults to true when missing +// +// Files with malformed frontmatter are reported with errors but do not abort +// the scan. +func AuditDodSchema(issuesDir, flowsDir string) (DodSchemaReport, error) { + var report DodSchemaReport + + collect := func(root, typ string) error { + if root == "" { + return nil + } + info, err := os.Stat(root) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + if !info.IsDir() { + return nil + } + return filepath.WalkDir(root, func(p string, d os.DirEntry, walkErr error) error { + if walkErr != nil { + return nil + } + if d.IsDir() { + return nil + } + if !strings.HasSuffix(p, ".md") { + return nil + } + // Skip README/INDEX/template/AGENT_GUIDE — convention files, not + // real issues/flows. + base := strings.ToLower(filepath.Base(p)) + if base == "readme.md" || base == "index.md" || base == "template.md" || base == "agent_guide.md" || base == "taxonomy.md" { + return nil + } + entry := parseDodFile(p, typ) + report.Files = append(report.Files, entry) + return nil + }) + } + + if err := collect(issuesDir, "issue"); err != nil { + return report, fmt.Errorf("audit_dod_schema: scan issues: %w", err) + } + if err := collect(flowsDir, "flow"); err != nil { + return report, fmt.Errorf("audit_dod_schema: scan flows: %w", err) + } + + sort.Slice(report.Files, func(i, j int) bool { + return report.Files[i].Path < report.Files[j].Path + }) + + report.TotalFiles = len(report.Files) + for _, f := range report.Files { + if len(f.Items) > 0 { + report.FilesWithItems++ + } + report.TotalItems += len(f.Items) + for _, e := range f.Errors { + // Each item-level validation error counts as one invalid item. + // Frontmatter-level errors (e.g. malformed YAML) also count. + if strings.HasPrefix(e, "item ") || strings.Contains(e, "duplicate id") || strings.Contains(e, "malformed") { + report.InvalidItems++ + } + } + } + return report, nil +} + +// parseDodFile reads a single .md, extracts the YAML frontmatter, parses the +// dod_evidence_schema block (if any), and validates each item. +func parseDodFile(path, typ string) DodSchemaIssue { + entry := DodSchemaIssue{Path: path, Type: typ} + data, err := os.ReadFile(path) + if err != nil { + entry.Errors = append(entry.Errors, fmt.Sprintf("read error: %v", err)) + return entry + } + s := string(data) + if !strings.HasPrefix(s, "---") { + // No frontmatter — silently skip (not every .md must have one). + return entry + } + // Skip leading "---\n" (4 bytes when LF, 5 when CRLF). + rest := s[3:] + if strings.HasPrefix(rest, "\r\n") { + rest = rest[2:] + } else if strings.HasPrefix(rest, "\n") { + rest = rest[1:] + } + end := strings.Index(rest, "\n---") + if end < 0 { + // No closing --- — treat as malformed but do not crash. + entry.Errors = append(entry.Errors, "malformed frontmatter: missing closing ---") + return entry + } + fm := rest[:end] + + var raw dodRawFrontmatter + if err := yaml.Unmarshal([]byte(fm), &raw); err != nil { + entry.Errors = append(entry.Errors, fmt.Sprintf("malformed frontmatter yaml: %v", err)) + return entry + } + if len(raw.DodEvidenceSchema) == 0 { + return entry + } + + seen := map[string]struct{}{} + for i, it := range raw.DodEvidenceSchema { + item := DodSchemaItem{ + ID: strings.TrimSpace(it.ID), + Kind: strings.TrimSpace(it.Kind), + Expected: strings.TrimSpace(it.Expected), + Required: true, // default + } + if it.Required != nil { + item.Required = *it.Required + } + + // Validation — errors are reported but the item is still appended so + // the caller sees the (partial) data. + label := item.ID + if label == "" { + label = fmt.Sprintf("#%d", i) + entry.Errors = append(entry.Errors, fmt.Sprintf("item %s missing id", label)) + } else if _, dup := seen[item.ID]; dup { + entry.Errors = append(entry.Errors, fmt.Sprintf("item '%s' duplicate id", item.ID)) + } else { + seen[item.ID] = struct{}{} + } + if item.Kind == "" { + entry.Errors = append(entry.Errors, fmt.Sprintf("item '%s' missing kind (valid: screenshot|log|url|cmd)", label)) + } else if _, ok := dodValidKinds[item.Kind]; !ok { + entry.Errors = append(entry.Errors, fmt.Sprintf("item '%s' invalid kind '%s' (valid: screenshot|log|url|cmd)", label, item.Kind)) + } + if item.Expected == "" { + entry.Errors = append(entry.Errors, fmt.Sprintf("item '%s' empty expected", label)) + } + entry.Items = append(entry.Items, item) + } + return entry +} diff --git a/functions/infra/audit_dod_schema.md b/functions/infra/audit_dod_schema.md new file mode 100644 index 00000000..3f23c9a2 --- /dev/null +++ b/functions/infra/audit_dod_schema.md @@ -0,0 +1,66 @@ +--- +name: audit_dod_schema +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func AuditDodSchema(issuesDir, flowsDir string) (DodSchemaReport, error)" +description: "Escanea dev/issues/ y dev/flows/ (incluidos subdirectorios completed/) y para cada .md parsea el bloque dod_evidence_schema del frontmatter YAML. Valida que cada item tenga id unico, kind in {screenshot,log,url,cmd}, expected no vacio y required bool (default true). Read-only: no modifica nada. Devuelve un DodSchemaReport con files (uno por archivo con items o errores), totales y conteo de items invalidos. Tolerante a frontmatter ausente o malformed — registra el error en el archivo afectado y continua." +tags: [doctor, dod, evidence, frontmatter, taxonomy, validator] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: ["fmt", "os", "path/filepath", "sort", "strings", "gopkg.in/yaml.v3"] +params: + - name: issuesDir + desc: "ruta absoluta al directorio dev/issues/ del registry. Vacio = skip." + - name: flowsDir + desc: "ruta absoluta al directorio dev/flows/ del registry. Vacio = skip." +output: "DodSchemaReport con Files (slice de DodSchemaIssue por archivo escaneado), TotalFiles, FilesWithItems, TotalItems, InvalidItems. Cada DodSchemaIssue contiene Path, Type (issue|flow), Items parseados y Errors validados (item-level y frontmatter-level). Error retornado solo si el WalkDir falla; archivos individuales con errores se incluyen en Files." +tested: true +tests: + - "valid dod_evidence_schema block parsed" + - "invalid kind detected" + - "duplicate id detected" + - "empty expected detected" + - "malformed yaml frontmatter does not crash" + - "file without block returns empty Items" +test_file_path: "functions/infra/audit_dod_schema_test.go" +file_path: "functions/infra/audit_dod_schema.go" +--- + +## Ejemplo + +```go +report, err := infra.AuditDodSchema( + "/home/lucas/fn_registry/dev/issues", + "/home/lucas/fn_registry/dev/flows", +) +if err != nil { + log.Fatal(err) +} +fmt.Printf("scanned %d files, %d items, %d invalid\n", + report.TotalFiles, report.TotalItems, report.InvalidItems) +for _, f := range report.Files { + for _, e := range f.Errors { + fmt.Printf("%s: %s\n", f.Path, e) + } +} +``` + +## Cuando usarla + +Antes de cerrar un issue o flow para confirmar que el bloque `dod_evidence_schema:` del frontmatter cumple el contrato canonico (issue 0114). La usa `fn doctor dod` para auditar todo el repo de un vistazo. Tambien util desde `/issue done` y `/flow done` para bloquear cierre si la DoD declarada tiene items invalidos. + +## Gotchas + +- Lee disco — clasificada `impure` aunque sea read-only. No escribe nada. +- Archivos sin frontmatter (no empiezan con `---`) se incluyen en Files con Items vacios y Errors vacios. No es error. +- Frontmatter sin bloque `dod_evidence_schema:` -> Items vacios, sin error. El bloque es opcional. +- `required:` ausente se trata como `true` (default conservador). +- README.md, INDEX.md, template.md, AGENT_GUIDE.md, TAXONOMY.md se ignoran (convencion de carpeta, no son issues/flows reales). +- Subdirectorios `completed/` se escanean igual que la raiz — un issue cerrado con DoD invalida sigue apareciendo en el reporte. +- YAML malformed no crashea — se registra como `malformed frontmatter yaml: ` en `Errors` del archivo y se continua.