From 5c7ff8d7616c7bef2cb77bf30a46f62a89d7b692 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Tue, 19 May 2026 01:45:54 +0200 Subject: [PATCH] feat(0121b): audit_e2e_coverage_go_infra + fn doctor e2e-coverage subcmd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Crea functions/infra/audit_e2e_coverage.go: AuditE2ECoverage(roots) escanea app.md recursivamente, detecta e2e_checks: en frontmatter, retorna E2ECoverageReport{total, with_checks, missing, coverage_pct}. - Crea functions/infra/e2e_coverage_report.go: tipo E2ECoverageReport con JSON tags (total, with_checks, missing, coverage_pct). - Crea types/infra/e2e_coverage_report.md: metadata del tipo para registry. - Crea functions/infra/audit_e2e_coverage.md: documentacion self-contained con Ejemplo, Cuando usarla, Gotchas. - Crea functions/infra/audit_e2e_coverage_test.go: 3 tests (empty, all-covered, partial) — todos pasan. - Edita cmd/fn/doctor.go: agrega case "e2e-coverage" -> doctorE2ECoverage(). Output text (tabla tabwriter + lista de apps missing) y --json (E2ECoverageReport). Acceptance verificado: fn doctor e2e-coverage --json -> {total, with_checks, missing, coverage_pct} OK fn doctor e2e-coverage -> tabla text OK go test ./functions/infra/... -> 3/3 PASS fn show audit_e2e_coverage_go_infra -> indexada OK task_run: task_d285372493cce2e6 iter 1 Co-authored-by: fn-orquestador --- cmd/fn/doctor.go | 41 ++++++++ functions/infra/audit_e2e_coverage.go | 115 ++++++++++++++++++++ functions/infra/audit_e2e_coverage.md | 57 ++++++++++ functions/infra/audit_e2e_coverage_test.go | 116 +++++++++++++++++++++ functions/infra/e2e_coverage_report.go | 9 ++ types/infra/e2e_coverage_report.md | 30 ++++++ 6 files changed, 368 insertions(+) create mode 100644 functions/infra/audit_e2e_coverage.go create mode 100644 functions/infra/audit_e2e_coverage.md create mode 100644 functions/infra/audit_e2e_coverage_test.go create mode 100644 functions/infra/e2e_coverage_report.go create mode 100644 types/infra/e2e_coverage_report.md diff --git a/cmd/fn/doctor.go b/cmd/fn/doctor.go index e863bcb3..bee22663 100644 --- a/cmd/fn/doctor.go +++ b/cmd/fn/doctor.go @@ -68,6 +68,8 @@ func cmdDoctor(args []string) { doctorModules(r, jsonOut) case "dod": doctorDod(r, jsonOut) + case "e2e-coverage": + doctorE2ECoverage(r, jsonOut) default: fmt.Fprintf(os.Stderr, "unknown doctor subcommand: %s\n", sub) doctorUsage() @@ -97,6 +99,7 @@ Subcommands: 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) + e2e-coverage Porcentaje de apps con e2e_checks declarado en su app.md (issue 0121b) Flags: --json Salida JSON (para scripting/agentes) @@ -685,3 +688,41 @@ func doctorDod(root string, jsonOut bool) { } } } + +func doctorE2ECoverage(root string, jsonOut bool) { + roots := []string{ + filepath.Join(root, "apps"), + filepath.Join(root, "cpp", "apps"), + filepath.Join(root, "projects"), + } + report, err := infra.AuditE2ECoverage(roots) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + if jsonOut { + emit(report) + return + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "METRIC\tVALUE") + fmt.Fprintf(w, "total\t%d\n", report.Total) + fmt.Fprintf(w, "with_checks\t%d\n", report.WithChecks) + fmt.Fprintf(w, "missing\t%d\n", len(report.Missing)) + fmt.Fprintf(w, "coverage_pct\t%.2f%%\n", report.CoveragePct) + w.Flush() + + if len(report.Missing) > 0 { + fmt.Println("\nApps without e2e_checks:") + rel := func(p string) string { + if r, err := filepath.Rel(root, p); err == nil { + return r + } + return p + } + for _, m := range report.Missing { + fmt.Printf(" %s\n", rel(m)) + } + } +} diff --git a/functions/infra/audit_e2e_coverage.go b/functions/infra/audit_e2e_coverage.go new file mode 100644 index 00000000..45380d07 --- /dev/null +++ b/functions/infra/audit_e2e_coverage.go @@ -0,0 +1,115 @@ +package infra + +import ( + "fmt" + "math" + "os" + "path/filepath" + "strings" +) + +// AuditE2ECoverage walks each directory in roots looking for app.md files and +// reports which ones declare e2e_checks in their YAML frontmatter. +// +// For each app.md found, it reads the file and searches for the substring +// "e2e_checks:" inside the first frontmatter block (between the opening and +// closing "---" delimiters). Files that contain the key count as covered. +// +// Parameters: +// - roots: slice of directory paths to scan (e.g. ["apps", "cpp/apps", "projects"]) +// +// Returns an E2ECoverageReport with total, with_checks, missing paths and +// coverage_pct rounded to 2 decimal places. Returns a non-nil error only when +// filesystem I/O fails for a reason other than a missing/empty root. +func AuditE2ECoverage(roots []string) (E2ECoverageReport, error) { + var report E2ECoverageReport + + for _, root := range roots { + info, err := os.Stat(root) + if err != nil { + if os.IsNotExist(err) { + continue + } + return report, fmt.Errorf("audit_e2e_coverage: stat %q: %w", root, err) + } + if !info.IsDir() { + continue + } + + walkErr := filepath.WalkDir(root, func(p string, d os.DirEntry, we error) error { + if we != nil { + // Skip unreadable entries without aborting the whole walk. + return nil + } + if d.IsDir() { + return nil + } + if filepath.Base(p) != "app.md" { + return nil + } + + report.Total++ + + data, err := os.ReadFile(p) + if err != nil { + // Count but mark as missing — we cannot determine coverage. + report.Missing = append(report.Missing, p) + return nil + } + + if hasE2EChecks(string(data)) { + report.WithChecks++ + } else { + report.Missing = append(report.Missing, p) + } + return nil + }) + if walkErr != nil { + return report, fmt.Errorf("audit_e2e_coverage: walk %q: %w", root, walkErr) + } + } + + report.CoveragePct = computeCoveragePct(report.WithChecks, report.Total) + return report, nil +} + +// hasE2EChecks returns true when the app.md content contains "e2e_checks:" +// inside the YAML frontmatter block (between the first pair of "---" lines). +// If the file has no frontmatter, it falls back to a whole-file scan so that +// non-standard formats are also handled gracefully. +func hasE2EChecks(content string) bool { + const key = "e2e_checks:" + + if !strings.HasPrefix(content, "---") { + // No frontmatter delimiter — scan full content. + return strings.Contains(content, key) + } + + // Skip the opening "---". + rest := content[3:] + if strings.HasPrefix(rest, "\r\n") { + rest = rest[2:] + } else if strings.HasPrefix(rest, "\n") { + rest = rest[1:] + } + + // Find the closing "---". + end := strings.Index(rest, "\n---") + if end < 0 { + // Malformed frontmatter — scan full content. + return strings.Contains(content, key) + } + + frontmatter := rest[:end] + return strings.Contains(frontmatter, key) +} + +// computeCoveragePct returns (withChecks/total)*100 rounded to 2 decimal +// places. Returns 0.0 when total is zero to avoid division by zero. +func computeCoveragePct(withChecks, total int) float64 { + if total == 0 { + return 0.0 + } + raw := float64(withChecks) / float64(total) * 100.0 + return math.Round(raw*100) / 100 +} diff --git a/functions/infra/audit_e2e_coverage.md b/functions/infra/audit_e2e_coverage.md new file mode 100644 index 00000000..7ca5eeda --- /dev/null +++ b/functions/infra/audit_e2e_coverage.md @@ -0,0 +1,57 @@ +--- +name: audit_e2e_coverage +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func AuditE2ECoverage(roots []string) (E2ECoverageReport, error)" +description: "Escanea directorios en busca de app.md y reporta cuales declaran e2e_checks en su frontmatter YAML. Retorna total, with_checks, missing (paths relativos) y coverage_pct (0-100). Impuro por lectura de filesystem." +tags: [e2e_checks, coverage, audit, doctor, infra] +params: + - name: roots + desc: "lista de directorios raiz a escanear recursivamente buscando app.md (ej. ['apps', 'cpp/apps', 'projects']). Directorios inexistentes se ignoran silenciosamente." +output: "E2ECoverageReport con total/with_checks/missing/coverage_pct. Error no nil solo ante fallo real de I/O." +uses_functions: [] +uses_types: ["e2e_coverage_report_go_infra"] +returns: ["e2e_coverage_report_go_infra"] +returns_optional: false +error_type: "error_go_core" +imports: ["fmt", "math", "os", "path/filepath", "strings"] +tested: true +tests: + - "directorio vacio sin app.md retorna Total=0 y CoveragePct=0" + - "todos los app.md con e2e_checks retorna CoveragePct=100" + - "cobertura parcial calcula CoveragePct correcto y puebla Missing" +test_file_path: "functions/infra/audit_e2e_coverage_test.go" +file_path: "functions/infra/audit_e2e_coverage.go" +--- + +## Ejemplo + +```go +report, err := infra.AuditE2ECoverage([]string{"apps", "cpp/apps", "projects"}) +if err != nil { + log.Fatal(err) +} +fmt.Printf("Coverage: %.2f%% (%d/%d apps)\n", report.CoveragePct, report.WithChecks, report.Total) +for _, m := range report.Missing { + fmt.Println(" missing:", m) +} +``` + +## Cuando usarla + +Usar como backend de `fn doctor e2e-coverage` para detectar apps sin contrato de validacion. +Tambien util antes de crear proposals `e2e_check_add`: llamar primero para obtener la lista +de apps que necesitan cobertura y priorizar las que mas se usan. + +## Gotchas + +- Solo detecta `e2e_checks:` como substring en el frontmatter — no valida la estructura YAML + completa del bloque. Un `# e2e_checks:` comentado pasaria como cubierto. +- Los paths en `Missing` son los paths tal como los devuelve `filepath.WalkDir`, que pueden + ser absolutos si los elementos de `roots` son absolutos. Para comparacion reproducible, + pasar roots relativas al cwd. +- Un `app.md` no legible (permisos) se cuenta en `Total` y se añade a `Missing` — no aborta + el scan. diff --git a/functions/infra/audit_e2e_coverage_test.go b/functions/infra/audit_e2e_coverage_test.go new file mode 100644 index 00000000..c71f7aa0 --- /dev/null +++ b/functions/infra/audit_e2e_coverage_test.go @@ -0,0 +1,116 @@ +package infra + +import ( + "math" + "os" + "path/filepath" + "testing" +) + +func TestAuditE2ECoverage_EmptyRoot(t *testing.T) { + dir := t.TempDir() + // Empty directory — no app.md files. + report, err := AuditE2ECoverage([]string{dir}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if report.Total != 0 { + t.Errorf("Total: got %d, want 0", report.Total) + } + if report.WithChecks != 0 { + t.Errorf("WithChecks: got %d, want 0", report.WithChecks) + } + if report.CoveragePct != 0.0 { + t.Errorf("CoveragePct: got %f, want 0.0", report.CoveragePct) + } + if len(report.Missing) != 0 { + t.Errorf("Missing: got %v, want empty", report.Missing) + } +} + +func TestAuditE2ECoverage_AllCovered(t *testing.T) { + dir := t.TempDir() + + apps := []struct { + subdir string + content string + }{ + { + "apps/registry_dashboard", + "---\nname: registry_dashboard\ne2e_checks:\n - id: build\n cmd: go build .\n---\n", + }, + { + "apps/kanban", + "---\nname: kanban\ne2e_checks:\n - id: tests\n cmd: go test ./...\n---\n", + }, + } + + for _, a := range apps { + full := filepath.Join(dir, a.subdir) + if err := os.MkdirAll(full, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(full, "app.md"), []byte(a.content), 0o644); err != nil { + t.Fatal(err) + } + } + + report, err := AuditE2ECoverage([]string{filepath.Join(dir, "apps")}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if report.Total != 2 { + t.Errorf("Total: got %d, want 2", report.Total) + } + if report.WithChecks != 2 { + t.Errorf("WithChecks: got %d, want 2", report.WithChecks) + } + if report.CoveragePct != 100.0 { + t.Errorf("CoveragePct: got %f, want 100.0", report.CoveragePct) + } + if len(report.Missing) != 0 { + t.Errorf("Missing: got %v, want empty", report.Missing) + } +} + +func TestAuditE2ECoverage_PartialCoverage(t *testing.T) { + dir := t.TempDir() + + covered := filepath.Join(dir, "apps", "registry_dashboard") + uncovered := filepath.Join(dir, "apps", "legacy_app") + for _, d := range []string{covered, uncovered} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + + // app.md with e2e_checks + covContent := "---\nname: registry_dashboard\ne2e_checks:\n - id: smoke\n cmd: ./registry_dashboard --help\n---\n" + if err := os.WriteFile(filepath.Join(covered, "app.md"), []byte(covContent), 0o644); err != nil { + t.Fatal(err) + } + + // app.md without e2e_checks + uncovContent := "---\nname: legacy_app\ndescription: A legacy app without e2e coverage.\n---\n" + if err := os.WriteFile(filepath.Join(uncovered, "app.md"), []byte(uncovContent), 0o644); err != nil { + t.Fatal(err) + } + + report, err := AuditE2ECoverage([]string{filepath.Join(dir, "apps")}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if report.Total != 2 { + t.Errorf("Total: got %d, want 2", report.Total) + } + if report.WithChecks != 1 { + t.Errorf("WithChecks: got %d, want 1", report.WithChecks) + } + wantPct := 50.0 + if math.Abs(report.CoveragePct-wantPct) > 0.01 { + t.Errorf("CoveragePct: got %f, want %f", report.CoveragePct, wantPct) + } + if len(report.Missing) != 1 { + t.Errorf("Missing len: got %d, want 1", len(report.Missing)) + } +} diff --git a/functions/infra/e2e_coverage_report.go b/functions/infra/e2e_coverage_report.go new file mode 100644 index 00000000..ad894dae --- /dev/null +++ b/functions/infra/e2e_coverage_report.go @@ -0,0 +1,9 @@ +package infra + +// E2ECoverageReport summarises e2e_checks coverage across scanned apps. +type E2ECoverageReport struct { + Total int `json:"total"` + WithChecks int `json:"with_checks"` + Missing []string `json:"missing"` // relative paths to app.md files without e2e_checks + CoveragePct float64 `json:"coverage_pct"` // 0-100, 2 decimal precision +} diff --git a/types/infra/e2e_coverage_report.md b/types/infra/e2e_coverage_report.md new file mode 100644 index 00000000..3b8f1f89 --- /dev/null +++ b/types/infra/e2e_coverage_report.md @@ -0,0 +1,30 @@ +--- +name: e2e_coverage_report +lang: go +domain: infra +version: "1.0.0" +algebraic: product +definition: | + type E2ECoverageReport struct { + Total int `json:"total"` + WithChecks int `json:"with_checks"` + Missing []string `json:"missing"` + CoveragePct float64 `json:"coverage_pct"` + } +description: "Reporte de cobertura de e2e_checks sobre apps escaneadas. Total es el numero de app.md encontrados, WithChecks los que declaran e2e_checks:, Missing los paths relativos sin cobertura, y CoveragePct el porcentaje 0-100 con 2 decimales." +tags: [e2e, coverage, audit, doctor, report, infra] +uses_types: [] +file_path: "functions/infra/e2e_coverage_report.go" +--- + +## Campos + +- `total`: numero de archivos `app.md` encontrados en los directorios escaneados. +- `with_checks`: cuantos de esos `app.md` declaran el bloque `e2e_checks:` en su frontmatter YAML. +- `missing`: lista de paths relativos a los `app.md` que no tienen `e2e_checks:`. +- `coverage_pct`: `(with_checks / total) * 100`, redondeado a 2 decimales. `0.0` cuando `total == 0`. + +## Notas + +Retornado por `audit_e2e_coverage_go_infra`. Consumido por `fn doctor e2e-coverage` +y por el bucle reactivo para generar proposals `e2e_check_add` en apps sin cobertura. -- 2.52.0