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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 <grupo>.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_<x> 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: <err>` en `Errors` del archivo y se continua.
|
||||
Reference in New Issue
Block a user