feat(0121b): audit_e2e_coverage_go_infra + fn doctor e2e-coverage subcmd
- 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 <noreply@fn-registry>
This commit is contained in:
@@ -68,6 +68,8 @@ func cmdDoctor(args []string) {
|
|||||||
doctorModules(r, jsonOut)
|
doctorModules(r, jsonOut)
|
||||||
case "dod":
|
case "dod":
|
||||||
doctorDod(r, jsonOut)
|
doctorDod(r, jsonOut)
|
||||||
|
case "e2e-coverage":
|
||||||
|
doctorE2ECoverage(r, jsonOut)
|
||||||
default:
|
default:
|
||||||
fmt.Fprintf(os.Stderr, "unknown doctor subcommand: %s\n", sub)
|
fmt.Fprintf(os.Stderr, "unknown doctor subcommand: %s\n", sub)
|
||||||
doctorUsage()
|
doctorUsage()
|
||||||
@@ -97,6 +99,7 @@ Subcommands:
|
|||||||
app-location Detecta artefactos (apps/analysis) en carpetas de lenguaje (cpp/apps/, etc.) - issue 0096
|
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
|
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)
|
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:
|
Flags:
|
||||||
--json Salida JSON (para scripting/agentes)
|
--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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user