merge(0121b): fn doctor e2e-coverage

task_run task_d285372493cce2e6 converged 1 iter / 4 min. 4/4 acceptance.

functions/infra/audit_e2e_coverage.go + .md + _test.go
types/infra/e2e_coverage_report.md
cmd/fn/doctor.go (subcmd e2e-coverage)

Cierra: dev/issues/0121b-fn-doctor-e2e-coverage.md
This commit is contained in:
2026-05-18 23:47:32 +00:00
6 changed files with 368 additions and 0 deletions
+41
View File
@@ -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_<x> 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))
}
}
}
+115
View File
@@ -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
}
+57
View File
@@ -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.
+116
View File
@@ -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))
}
}
+9
View File
@@ -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
}
+30
View File
@@ -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.