feat(0130): kanban_cpp v2 — backend Go + 5 registry parser fns + epic/sub-issues

Registry (issue 0130a):
- 5 fns infra: parse_issue_md, write_issue_md, scan_issues_dir,
  scan_flows_dir, watch_dir_fsnotify
- 3 tipos: Issue, Flow, FsEvent
- Tests round-trip + scan reales + watcher fsnotify (all PASS)
- Capability group 'kanban' nuevo (docs/capabilities/kanban.md)

Apps:
- apps/kanban_cpp/ (sub-repo) — frontend ImGui: board drag-drop,
  flows, filters, detail con CSV editors
- apps/kanban_cpp/backend/ — Go service port 8487: REST + SSE +
  fsnotify watcher, parser bidireccional MD<->SQLite cache

Issues:
- dev/issues/0130-kanban-cpp-v2.md (epic)
- 0130a parser, 0130b backend, 0130c frontend

CMakeLists.txt: add_subdirectory apps/kanban_cpp (registrado por
init_cpp_app scaffolder).

End-to-end verde: backend devuelve 189 issues + 9 flows; PATCH a
/api/issues/{id} reescribe .md (solo frontmatter, body intacto);
frontend --self-test exit 0; tests Go infra 5/5 PASS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Egutierrez
2026-05-22 22:20:15 +02:00
parent e387c91b4c
commit c468b24d2b
31 changed files with 1706 additions and 9 deletions
+19
View File
@@ -0,0 +1,19 @@
package infra
// Flow representa el frontmatter de un archivo Markdown de flow en dev/flows/.
// Los campos de runtime (FilePath, MtimeNs) no se serializaran en YAML.
type Flow struct {
ID string `yaml:"id"`
Title string `yaml:"title,omitempty"`
Status string `yaml:"status,omitempty"`
Kind string `yaml:"kind,omitempty"`
Tags []string `yaml:"tags,omitempty"`
// Para flows con formato name/status por separado (ej. hn-top-stories).
Name string `yaml:"name,omitempty"`
Priority string `yaml:"priority,omitempty"`
// Campos de runtime — NO se serializan en YAML.
FilePath string `yaml:"-"`
MtimeNs int64 `yaml:"-"`
}
+8
View File
@@ -0,0 +1,8 @@
package infra
// FsEvent representa un evento del watcher de sistema de archivos.
// Op es uno de: "create", "write", "remove", "rename".
type FsEvent struct {
Path string // ruta absoluta del archivo afectado
Op string // "create" | "write" | "remove" | "rename"
}
+25
View File
@@ -0,0 +1,25 @@
package infra
// Issue representa el frontmatter de un archivo Markdown de issue en dev/issues/.
// Los campos de runtime (FilePath, MtimeNs, Completed) no se serialiaran en YAML.
type Issue struct {
ID string `yaml:"id"`
Title string `yaml:"title"`
Status string `yaml:"status"`
Type string `yaml:"type"`
Domain []string `yaml:"domain"`
Scope string `yaml:"scope"`
Priority string `yaml:"priority"`
Depends []string `yaml:"depends"`
Blocks []string `yaml:"blocks"`
Related []string `yaml:"related"`
Tags []string `yaml:"tags"`
Flow string `yaml:"flow,omitempty"`
Created string `yaml:"created"`
Updated string `yaml:"updated"`
// Campos de runtime — NO se serializan en YAML.
FilePath string `yaml:"-"`
MtimeNs int64 `yaml:"-"`
Completed bool `yaml:"-"` // true si el archivo vive en dev/issues/completed/
}
+87
View File
@@ -0,0 +1,87 @@
package infra
import (
"bytes"
"fmt"
"os"
"strings"
"gopkg.in/yaml.v3"
)
// ParseIssueMd lee un archivo Markdown de issue, extrae y parsea el frontmatter YAML
// en un struct Issue, y devuelve el body (todo lo que va despues del segundo "---").
// FilePath e MtimeNs se rellenan con los valores del archivo en disco.
// Completed se deduce del path (contiene "/completed/").
func ParseIssueMd(path string) (Issue, []byte, error) {
data, err := os.ReadFile(path)
if err != nil {
return Issue{}, nil, fmt.Errorf("parse_issue_md: read %s: %w", path, err)
}
info, err := os.Stat(path)
if err != nil {
return Issue{}, nil, fmt.Errorf("parse_issue_md: stat %s: %w", path, err)
}
fm, body, err := splitFrontmatter(data)
if err != nil {
return Issue{}, nil, fmt.Errorf("parse_issue_md: %s: %w", path, err)
}
var iss Issue
if err := yaml.Unmarshal(fm, &iss); err != nil {
return Issue{}, nil, fmt.Errorf("parse_issue_md: yaml %s: %w", path, err)
}
iss.FilePath = path
iss.MtimeNs = info.ModTime().UnixNano()
iss.Completed = strings.Contains(path, "/completed/")
return iss, body, nil
}
// splitFrontmatter divide el contenido en bloque YAML y body.
// Espera formato: "---\n<yaml>\n---\n<body>".
// Devuelve el YAML (sin los delimitadores) y el body (incluye el \n posterior al segundo ---).
func splitFrontmatter(data []byte) ([]byte, []byte, error) {
sep := []byte("---")
newline := []byte("\n")
// El archivo debe empezar con "---\n"
if !bytes.HasPrefix(data, append(sep, '\n')) {
return nil, nil, fmt.Errorf("missing opening '---' delimiter")
}
// Buscar el segundo "---" (en su propia linea)
rest := data[len(sep)+1:] // avanza pasado el primer "---\n"
idx := -1
for i := 0; i <= len(rest)-len(sep); i++ {
// Debe estar al inicio de linea: posicion 0 o precedido por '\n'
atLineStart := i == 0 || rest[i-1] == '\n'
if atLineStart && bytes.Equal(rest[i:i+len(sep)], sep) {
// El separador debe ir seguido de '\n' o EOF
end := i + len(sep)
if end == len(rest) || rest[end] == '\n' {
idx = i
break
}
}
}
if idx == -1 {
return nil, nil, fmt.Errorf("missing closing '---' delimiter")
}
fm := rest[:idx]
// El body empieza despues del segundo "---\n"
bodyStart := idx + len(sep)
if bodyStart < len(rest) && rest[bodyStart] == '\n' {
bodyStart++
}
body := rest[bodyStart:]
_ = newline
return fm, body, nil
}
+51
View File
@@ -0,0 +1,51 @@
---
name: parse_issue_md
kind: function
lang: go
domain: infra
version: "0.1.0"
purity: impure
signature: "func ParseIssueMd(path string) (Issue, []byte, error)"
description: "Lee un archivo Markdown de issue (dev/issues/*.md), extrae el frontmatter YAML en un struct Issue y devuelve el body tal como esta en disco. Rellena FilePath, MtimeNs y Completed (deduce de si el path contiene /completed/)."
tags: [issue, parser, frontmatter, yaml, kanban, dev-ux, kanban]
uses_functions: []
uses_types: [issue_go_infra]
returns: [issue_go_infra]
returns_optional: false
error_type: "error_go_core"
imports: ["bytes", "fmt", "os", "strings", "gopkg.in/yaml.v3"]
params:
- name: path
desc: "Ruta absoluta o relativa al archivo .md del issue (ej: dev/issues/0130-kanban-cpp-v2.md)"
output: "Struct Issue con todos los campos del frontmatter, byte slice con el body MD, y error si el archivo no existe o el YAML es invalido"
tested: true
tests:
- "parsea 0130-kanban-cpp-v2 correctamente"
- "completed flag se deduce del path"
- "error en archivo inexistente"
- "fixture preserva campos"
test_file_path: "functions/infra/parse_issue_md_test.go"
file_path: "functions/infra/parse_issue_md.go"
---
## Ejemplo
```go
iss, body, err := infra.ParseIssueMd("dev/issues/0130-kanban-cpp-v2.md")
if err != nil {
log.Fatal(err)
}
fmt.Printf("ID=%s Status=%s Domain=%v\n", iss.ID, iss.Status, iss.Domain)
// body contiene el Markdown despues del segundo ---
```
## Cuando usarla
Cuando necesites leer el frontmatter de un issue del registry para mostrarlo, modificarlo o indexarlo. Usar como base de `scan_issues_dir_go_infra` (que la llama por cada archivo) o cuando necesites acceso al body MD ademas del struct.
## Gotchas
- El body devuelto incluye el `\n` inmediatamente posterior al segundo `---`. No se normaliza.
- Si el archivo tiene un solo `---` (sin segundo delimitador), retorna error. Issues sin frontmatter no son validos.
- `Completed` se infiere del path, no del campo `status` del YAML — un issue con `status: completado` que vive en `dev/issues/` (no en `completed/`) tendra `Completed=false`.
- Los campos `Depends`, `Blocks`, `Related`, `Tags`, `Domain` son `[]string` — si el YAML los omite quedan como `nil`, no slice vacio.
+101
View File
@@ -0,0 +1,101 @@
package infra
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func registryRoot() string {
_, thisFile, _, _ := runtime.Caller(0)
return filepath.Join(filepath.Dir(thisFile), "..", "..")
}
func TestParseIssueMd(t *testing.T) {
root := registryRoot()
t.Run("parsea 0130-kanban-cpp-v2 correctamente", func(t *testing.T) {
path := filepath.Join(root, "dev", "issues", "0130-kanban-cpp-v2.md")
iss, body, err := ParseIssueMd(path)
if err != nil {
t.Fatalf("ParseIssueMd error: %v", err)
}
if iss.ID != "0130" {
t.Errorf("ID: got %q, want %q", iss.ID, "0130")
}
if !strings.Contains(iss.Title, "Kanban C++ v2") {
t.Errorf("Title %q does not contain 'Kanban C++ v2'", iss.Title)
}
if iss.Status != "pendiente" {
t.Errorf("Status: got %q, want %q", iss.Status, "pendiente")
}
if len(iss.Domain) < 3 {
t.Errorf("Domain: got %d items, want >=3: %v", len(iss.Domain), iss.Domain)
}
if iss.FilePath != path {
t.Errorf("FilePath: got %q, want %q", iss.FilePath, path)
}
if iss.MtimeNs == 0 {
t.Error("MtimeNs should be non-zero")
}
if iss.Completed {
t.Error("Completed should be false for non-completed issue")
}
if len(body) == 0 {
t.Error("body should not be empty")
}
})
t.Run("completed flag se deduce del path", func(t *testing.T) {
fixturePath := filepath.Join(root, "functions", "infra", "testdata", "issue_fixture.fixture")
data, err := os.ReadFile(fixturePath)
if err != nil {
t.Fatalf("read fixture: %v", err)
}
completedDir := filepath.Join(t.TempDir(), "completed")
if err := os.MkdirAll(completedDir, 0755); err != nil {
t.Fatalf("mkdir: %v", err)
}
completedPath := filepath.Join(completedDir, "9999-fixture.md")
if err := os.WriteFile(completedPath, data, 0644); err != nil {
t.Fatalf("write: %v", err)
}
iss, _, err := ParseIssueMd(completedPath)
if err != nil {
t.Fatalf("ParseIssueMd error: %v", err)
}
if !iss.Completed {
t.Error("Completed should be true for path with /completed/")
}
})
t.Run("error en archivo inexistente", func(t *testing.T) {
_, _, err := ParseIssueMd("/nonexistent/path/issue.md")
if err == nil {
t.Error("expected error for nonexistent file")
}
})
t.Run("fixture preserva campos", func(t *testing.T) {
fixturePath := filepath.Join(root, "functions", "infra", "testdata", "issue_fixture.fixture")
iss, body, err := ParseIssueMd(fixturePath)
if err != nil {
t.Fatalf("ParseIssueMd error: %v", err)
}
if iss.ID != "9999" {
t.Errorf("ID: got %q, want %q", iss.ID, "9999")
}
if iss.Flow != "0001" {
t.Errorf("Flow: got %q, want %q", iss.Flow, "0001")
}
if len(iss.Depends) != 1 || iss.Depends[0] != "0001" {
t.Errorf("Depends: got %v, want [0001]", iss.Depends)
}
if !strings.Contains(string(body), "Este es el body") {
t.Errorf("body should contain fixture text, got: %s", string(body))
}
})
}
+83
View File
@@ -0,0 +1,83 @@
package infra
import (
"fmt"
"log"
"os"
"path/filepath"
"sort"
"strings"
"gopkg.in/yaml.v3"
)
// ScanFlowsDir escanea el directorio root (dev/flows/) y devuelve todos los Flows
// encontrados en *.md directos.
// Si un archivo falla al parsearse, se emite un warning al log y se continua.
// Los flows se devuelven ordenados por ID ascendente.
func ScanFlowsDir(root string) ([]Flow, error) {
matches, err := filepath.Glob(filepath.Join(root, "*.md"))
if err != nil {
return nil, fmt.Errorf("scan_flows_dir: glob: %w", err)
}
var flows []Flow
for _, path := range matches {
base := filepath.Base(path)
if strings.EqualFold(base, "INDEX.md") || strings.EqualFold(base, "README.md") || strings.EqualFold(base, "AGENT_GUIDE.md") {
continue
}
info, err := os.Stat(path)
if err != nil || !info.Mode().IsRegular() {
continue
}
f, err := parseFlowMd(path)
if err != nil {
log.Printf("scan_flows_dir: warning: skip %s: %v", path, err)
continue
}
flows = append(flows, f)
}
sort.Slice(flows, func(i, j int) bool {
return flows[i].ID < flows[j].ID
})
return flows, nil
}
// parseFlowMd parsea el frontmatter de un archivo dev/flows/*.md en un struct Flow.
func parseFlowMd(path string) (Flow, error) {
data, err := os.ReadFile(path)
if err != nil {
return Flow{}, fmt.Errorf("read %s: %w", path, err)
}
info, err := os.Stat(path)
if err != nil {
return Flow{}, fmt.Errorf("stat %s: %w", path, err)
}
fm, _, err := splitFrontmatter(data)
if err != nil {
return Flow{}, fmt.Errorf("frontmatter %s: %w", path, err)
}
var f Flow
if err := yaml.Unmarshal(fm, &f); err != nil {
return Flow{}, fmt.Errorf("yaml %s: %w", path, err)
}
// Algunos flows usan "name" y no "title" — normalizar
if f.Title == "" && f.Name != "" {
f.Title = f.Name
}
// Algunos flows usan entero como ID en el YAML — yaml.v3 lo convierte a string OK
f.FilePath = path
f.MtimeNs = info.ModTime().UnixNano()
return f, nil
}
+52
View File
@@ -0,0 +1,52 @@
---
name: scan_flows_dir
kind: function
lang: go
domain: infra
version: "0.1.0"
purity: impure
signature: "func ScanFlowsDir(root string) ([]Flow, error)"
description: "Escanea el directorio dev/flows/ (root) y devuelve todos los Flows encontrados en *.md directos. Skippea INDEX.md, README.md y AGENT_GUIDE.md. Si un archivo falla al parsearse emite warning y continua. Resultado ordenado por ID ascendente."
tags: [flow, scanner, frontmatter, yaml, dev-ux, kanban]
uses_functions: []
uses_types: [flow_go_infra]
returns: [flow_go_infra]
returns_optional: false
error_type: "error_go_core"
imports: ["fmt", "log", "os", "path/filepath", "sort", "strings", "gopkg.in/yaml.v3"]
params:
- name: root
desc: "Ruta al directorio dev/flows/ (absoluta o relativa)."
output: "Slice de Flow ordenado por ID asc con FilePath y MtimeNs rellenados. Flows con YAML malformado se omiten con warning."
tested: true
tests:
- "scan devuelve al menos 5 flows"
- "flow 0001 esta presente"
- "flows tienen FilePath y MtimeNs"
- "flows ordenados por ID asc"
test_file_path: "functions/infra/scan_flows_dir_test.go"
file_path: "functions/infra/scan_flows_dir.go"
---
## Ejemplo
```go
flows, err := infra.ScanFlowsDir("/home/lucas/fn_registry/dev/flows")
if err != nil {
log.Fatal(err)
}
fmt.Printf("Total flows: %d\n", len(flows))
for _, f := range flows {
fmt.Printf(" %s [%s] %s\n", f.ID, f.Status, f.Title)
}
```
## Cuando usarla
Al arrancar el backend de kanban_cpp para cargar el panel Flows. Tambien util para dashboards de estado del proyecto que necesiten listar flujos activos/pendientes.
## Gotchas
- El struct `Flow` tiene campos `Name` y `Title` porque algunos flows del registry usan `name:` y otros `title:` en el frontmatter. `parseFlowMd` normaliza: si `Title` esta vacio pero `Name` no, copia `Name` a `Title`.
- No tiene subdirectorio `completed/` equivalente — todos los flows activos e historicos viven en el mismo directorio raiz.
- La funcion `parseFlowMd` es interna (no exportada). Si necesitas parsear un flow individual, usa directamente `yaml.Unmarshal` o expone una funcion separada.
+66
View File
@@ -0,0 +1,66 @@
package infra
import (
"path/filepath"
"testing"
)
func TestScanFlowsDir(t *testing.T) {
root := registryRoot()
flowsDir := filepath.Join(root, "dev", "flows")
t.Run("scan devuelve al menos 5 flows", func(t *testing.T) {
flows, err := ScanFlowsDir(flowsDir)
if err != nil {
t.Fatalf("ScanFlowsDir: %v", err)
}
if len(flows) < 5 {
t.Errorf("expected >= 5 flows, got %d", len(flows))
}
})
t.Run("flow 0001 esta presente", func(t *testing.T) {
flows, err := ScanFlowsDir(flowsDir)
if err != nil {
t.Fatalf("ScanFlowsDir: %v", err)
}
found := false
for _, f := range flows {
if f.ID == "0001" {
found = true
break
}
}
if !found {
t.Error("flow 0001 not found in scan results")
}
})
t.Run("flows tienen FilePath y MtimeNs", func(t *testing.T) {
flows, err := ScanFlowsDir(flowsDir)
if err != nil {
t.Fatalf("ScanFlowsDir: %v", err)
}
for _, f := range flows {
if f.FilePath == "" {
t.Errorf("flow %q has empty FilePath", f.ID)
}
if f.MtimeNs == 0 {
t.Errorf("flow %q has zero MtimeNs", f.ID)
}
}
})
t.Run("flows ordenados por ID asc", func(t *testing.T) {
flows, err := ScanFlowsDir(flowsDir)
if err != nil {
t.Fatalf("ScanFlowsDir: %v", err)
}
for i := 1; i < len(flows); i++ {
if flows[i].ID < flows[i-1].ID {
t.Errorf("not sorted at index %d: %q < %q", i, flows[i].ID, flows[i-1].ID)
break
}
}
})
}
+62
View File
@@ -0,0 +1,62 @@
package infra
import (
"fmt"
"log"
"os"
"path/filepath"
"sort"
"strings"
)
// ScanIssuesDir escanea el directorio root (dev/issues/) y devuelve todos los Issues
// encontrados en *.md directos y en completed/*.md.
// Si un archivo falla al parsearse, se emite un warning al log y se continua.
// Los issues se devuelven ordenados por ID ascendente.
func ScanIssuesDir(root string) ([]Issue, error) {
// Verificar que el directorio raiz existe.
if _, err := os.Stat(root); err != nil {
return nil, fmt.Errorf("scan_issues_dir: root dir %s: %w", root, err)
}
var issues []Issue
// Patterns a escanear: archivos directos y completed/
patterns := []string{
filepath.Join(root, "*.md"),
filepath.Join(root, "completed", "*.md"),
}
for _, pattern := range patterns {
matches, err := filepath.Glob(pattern)
if err != nil {
return nil, fmt.Errorf("scan_issues_dir: glob %s: %w", pattern, err)
}
for _, path := range matches {
// Saltar INDEX.md y README.md
base := filepath.Base(path)
if strings.EqualFold(base, "INDEX.md") || strings.EqualFold(base, "README.md") {
continue
}
// Verificar que es un archivo regular
info, err := os.Stat(path)
if err != nil || !info.Mode().IsRegular() {
continue
}
iss, _, err := ParseIssueMd(path)
if err != nil {
log.Printf("scan_issues_dir: warning: skip %s: %v", path, err)
continue
}
issues = append(issues, iss)
}
}
sort.Slice(issues, func(i, j int) bool {
return issues[i].ID < issues[j].ID
})
return issues, nil
}
+54
View File
@@ -0,0 +1,54 @@
---
name: scan_issues_dir
kind: function
lang: go
domain: infra
version: "0.1.0"
purity: impure
signature: "func ScanIssuesDir(root string) ([]Issue, error)"
description: "Escanea el directorio dev/issues/ (root) y devuelve todos los Issues encontrados en *.md directos y en completed/*.md. Si un archivo falla al parsearse emite un warning al log y continua. Resultado ordenado por ID ascendente."
tags: [issue, scanner, frontmatter, yaml, dev-ux, kanban]
uses_functions: [parse_issue_md_go_infra]
uses_types: [issue_go_infra]
returns: [issue_go_infra]
returns_optional: false
error_type: "error_go_core"
imports: ["fmt", "log", "os", "path/filepath", "sort", "strings"]
params:
- name: root
desc: "Ruta al directorio dev/issues/ (absoluta o relativa). Debe existir o retorna error."
output: "Slice de Issue ordenado por ID asc. Incluye issues de completed/ con Completed=true. Issues con YAML malformado se omiten con warning."
tested: true
tests:
- "scan devuelve al menos 90 issues"
- "issue 0130 esta presente"
- "issues ordenados por ID asc"
- "completed issues tienen Completed=true"
- "directorio inexistente retorna error"
test_file_path: "functions/infra/scan_issues_dir_test.go"
file_path: "functions/infra/scan_issues_dir.go"
---
## Ejemplo
```go
issues, err := infra.ScanIssuesDir("/home/lucas/fn_registry/dev/issues")
if err != nil {
log.Fatal(err)
}
fmt.Printf("Total issues: %d\n", len(issues))
for _, iss := range issues {
fmt.Printf(" %s [%s] %s\n", iss.ID, iss.Status, iss.Title)
}
```
## Cuando usarla
Al arrancar el backend de kanban_cpp para poblar la cache SQLite inicial. Tambien util para cualquier herramienta que necesite un snapshot completo de todos los issues del proyecto (stats, dashboards, fn doctor).
## Gotchas
- Skippea automaticamente `INDEX.md` y `README.md` — no son issues.
- Si `completed/` no existe (no hay issues completados), no retorna error — devuelve los issues directos.
- La ordenacion es lexicografica por ID string, no numerica. `"0099" < "0100"` funciona bien con el formato de 4 digitos del registry.
- Un issue con YAML invalido no aborta el scan entero — solo ese archivo se omite con un `log.Printf` warning. Si necesitas comportamiento strict (abort en primer error), parsea manualmente con `ParseIssueMd`.
+74
View File
@@ -0,0 +1,74 @@
package infra
import (
"path/filepath"
"testing"
)
func TestScanIssuesDir(t *testing.T) {
root := registryRoot()
issuesDir := filepath.Join(root, "dev", "issues")
t.Run("scan devuelve al menos 90 issues", func(t *testing.T) {
issues, err := ScanIssuesDir(issuesDir)
if err != nil {
t.Fatalf("ScanIssuesDir: %v", err)
}
if len(issues) < 90 {
t.Errorf("expected >= 90 issues, got %d", len(issues))
}
})
t.Run("issue 0130 esta presente", func(t *testing.T) {
issues, err := ScanIssuesDir(issuesDir)
if err != nil {
t.Fatalf("ScanIssuesDir: %v", err)
}
found := false
for _, iss := range issues {
if iss.ID == "0130" {
found = true
break
}
}
if !found {
t.Error("issue 0130 not found in scan results")
}
})
t.Run("issues ordenados por ID asc", func(t *testing.T) {
issues, err := ScanIssuesDir(issuesDir)
if err != nil {
t.Fatalf("ScanIssuesDir: %v", err)
}
for i := 1; i < len(issues); i++ {
if issues[i].ID < issues[i-1].ID {
t.Errorf("not sorted at index %d: %q < %q", i, issues[i].ID, issues[i-1].ID)
break
}
}
})
t.Run("completed issues tienen Completed=true", func(t *testing.T) {
issues, err := ScanIssuesDir(issuesDir)
if err != nil {
t.Fatalf("ScanIssuesDir: %v", err)
}
completedCount := 0
for _, iss := range issues {
if iss.Completed {
completedCount++
}
}
if completedCount == 0 {
t.Error("expected at least some completed issues")
}
})
t.Run("directorio inexistente retorna error", func(t *testing.T) {
_, err := ScanIssuesDir("/nonexistent/dev/issues")
if err == nil {
t.Error("expected error for nonexistent directory")
}
})
}
+30
View File
@@ -0,0 +1,30 @@
---
id: "9999"
title: "Fixture issue con caracteres especiales: áéíóú & <test>"
status: pendiente
type: app
domain:
- core
- infra
scope: registry-only
priority: alta
depends:
- "0001"
blocks: []
related:
- "0100"
tags: [test, fixture, round-trip]
flow: "0001"
created: 2026-01-01
updated: 2026-05-22
---
# Fixture issue
Este es el body del issue. Contiene caracteres especiales: áéíóú & <test>.
## Sección
Linea con **negrita** y _cursiva_.
Final del body.
+135
View File
@@ -0,0 +1,135 @@
package infra
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"time"
"github.com/fsnotify/fsnotify"
)
// WatchDirFsnotify crea un watcher recursivo sobre root y todos sus subdirectorios.
// Emite FsEvent al canal devuelto con debounce de 200ms por path (si llegan multiples
// eventos del mismo archivo en la ventana, se emite solo el ultimo).
// Cierra el canal cuando ctx.Done() se dispara.
func WatchDirFsnotify(ctx context.Context, root string) (<-chan FsEvent, error) {
watcher, err := fsnotify.NewWatcher()
if err != nil {
return nil, fmt.Errorf("watch_dir_fsnotify: new watcher: %w", err)
}
// Anadir root y todos los subdirectorios recursivamente.
if err := addDirsRecursive(watcher, root); err != nil {
watcher.Close()
return nil, fmt.Errorf("watch_dir_fsnotify: add dirs: %w", err)
}
ch := make(chan FsEvent, 64)
go func() {
defer watcher.Close()
defer close(ch)
// Mapa de debounce: path -> (timer, ultimo op)
type pending struct {
timer *time.Timer
op string
}
debounce := make(map[string]*pending)
const debounceDelay = 200 * time.Millisecond
for {
select {
case <-ctx.Done():
// Cancelar todos los timers pendientes antes de salir.
for _, p := range debounce {
p.timer.Stop()
}
return
case event, ok := <-watcher.Events:
if !ok {
return
}
op := fsnotifyOpToString(event.Op)
if op == "" {
continue
}
path := event.Name
// Si el directorio nuevo fue creado, anadirlo al watcher.
if event.Op&fsnotify.Create != 0 {
if info, err := os.Stat(path); err == nil && info.IsDir() {
if err := watcher.Add(path); err != nil {
log.Printf("watch_dir_fsnotify: add new dir %s: %v", path, err)
}
}
}
// Debounce: resetear el timer si ya habia uno para este path.
if p, exists := debounce[path]; exists {
p.timer.Stop()
p.op = op
p.timer.Reset(debounceDelay)
} else {
p = &pending{op: op}
p.timer = time.AfterFunc(debounceDelay, func() {
select {
case ch <- FsEvent{Path: path, Op: p.op}:
case <-ctx.Done():
}
delete(debounce, path)
})
debounce[path] = p
}
case err, ok := <-watcher.Errors:
if !ok {
return
}
log.Printf("watch_dir_fsnotify: watcher error: %v", err)
}
}
}()
return ch, nil
}
// addDirsRecursive anade root y todos sus subdirectorios al watcher.
// Retorna error si root no existe o no es accesible.
func addDirsRecursive(watcher *fsnotify.Watcher, root string) error {
if _, err := os.Stat(root); err != nil {
return fmt.Errorf("root dir %s: %w", root, err)
}
return filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil // ignora errores de acceso en subdirs
}
if info.IsDir() {
return watcher.Add(path)
}
return nil
})
}
// fsnotifyOpToString convierte fsnotify.Op al string canonico del registry.
// Retorna "" para operaciones no mapeadas (CHMOD, etc.).
func fsnotifyOpToString(op fsnotify.Op) string {
switch {
case op&fsnotify.Create != 0:
return "create"
case op&fsnotify.Write != 0:
return "write"
case op&fsnotify.Remove != 0:
return "remove"
case op&fsnotify.Rename != 0:
return "rename"
default:
return ""
}
}
+61
View File
@@ -0,0 +1,61 @@
---
name: watch_dir_fsnotify
kind: function
lang: go
domain: infra
version: "0.1.0"
purity: impure
signature: "func WatchDirFsnotify(ctx context.Context, root string) (<-chan FsEvent, error)"
description: "Crea un watcher recursivo sobre root y todos sus subdirectorios usando fsnotify. Emite FsEvent al canal con debounce de 200ms por path (multiples eventos del mismo archivo en la ventana = un solo evento con la ultima op). Cierra el canal cuando ctx.Done(). Anade automaticamente nuevos subdirectorios creados en runtime."
tags: [watcher, fsnotify, filesystem, dev-ux, async, kanban]
uses_functions: []
uses_types: [fs_event_go_infra]
returns: [fs_event_go_infra]
returns_optional: false
error_type: "error_go_core"
imports: ["context", "fmt", "log", "os", "path/filepath", "time", "github.com/fsnotify/fsnotify"]
params:
- name: ctx
desc: "Context para cancelar el watcher. Al cancelar, el canal se cierra limpiamente."
- name: root
desc: "Directorio raiz a vigilar recursivamente. Debe existir o retorna error."
output: "Canal de solo lectura que emite FsEvent por cada cambio detectado (tras debounce). El canal se cierra cuando ctx se cancela o el watcher interno falla."
tested: true
tests:
- "detecta escritura de archivo"
- "canal se cierra cuando ctx cancela"
- "error en directorio inexistente"
- "debounce agrupa multiples escrituras"
test_file_path: "functions/infra/watch_dir_fsnotify_test.go"
file_path: "functions/infra/watch_dir_fsnotify.go"
---
## Ejemplo
```go
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ch, err := infra.WatchDirFsnotify(ctx, "/home/lucas/fn_registry/dev/issues")
if err != nil {
log.Fatal(err)
}
for ev := range ch {
fmt.Printf("event: op=%s path=%s\n", ev.Op, ev.Path)
// recargar el issue afectado en cache
}
```
## Cuando usarla
En el backend de kanban_cpp para detectar cambios externos en `dev/issues/` y `dev/flows/` (ediciones en el editor de texto del usuario) y propagar via SSE al frontend ImGui. Tambien util para cualquier daemon que necesite invalidar cache ante cambios en disco.
## Gotchas
- **Debounce por path**: si guardas el mismo archivo 5 veces en 200ms (ej. autoguardado del editor), recibes 1 evento, no 5. El `Op` del evento es el de la ultima operacion en la ventana.
- **Subdirectorios dinamicos**: si se crea un subdirectorio nuevo mientras el watcher esta activo, se anade automaticamente al watcher. Los archivos creados dentro del nuevo subdir se detectan.
- **Eventos CHMOD ignorados**: solo se emiten `create`, `write`, `remove`, `rename`. Cambios de permisos no disparan eventos.
- **Canal con buffer 64**: si el consumidor es lento y el buffer se llena, eventos adicionales se bloquean en la goroutine interna. Con debounce 200ms es poco probable en uso normal.
- **No filtra por extension**: emite eventos para cualquier archivo en el arbol, no solo `.md`. El consumidor debe filtrar si solo le interesan ciertos tipos.
- **Linux inotify limit**: en sistemas con muchos subdirectorios, puede alcanzar el limite de `fs.inotify.max_user_watches` (default 8192). Aumentar con `sysctl fs.inotify.max_user_watches=65536` si se observan errores en el log.
+129
View File
@@ -0,0 +1,129 @@
package infra
import (
"context"
"os"
"path/filepath"
"testing"
"time"
)
func TestWatchDirFsnotify(t *testing.T) {
t.Run("detecta escritura de archivo", func(t *testing.T) {
tmpDir := t.TempDir()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ch, err := WatchDirFsnotify(ctx, tmpDir)
if err != nil {
t.Fatalf("WatchDirFsnotify: %v", err)
}
// Dar tiempo al watcher para arrancar
time.Sleep(50 * time.Millisecond)
// Escribir un archivo
testFile := filepath.Join(tmpDir, "test.md")
if err := os.WriteFile(testFile, []byte("hello"), 0644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
// Esperar evento (debounce 200ms + margen)
select {
case ev, ok := <-ch:
if !ok {
t.Fatal("channel closed unexpectedly")
}
if ev.Path != testFile {
t.Errorf("Path: got %q, want %q", ev.Path, testFile)
}
if ev.Op != "create" && ev.Op != "write" {
t.Errorf("Op: got %q, want 'create' or 'write'", ev.Op)
}
case <-ctx.Done():
t.Fatal("timeout waiting for fs event")
}
})
t.Run("canal se cierra cuando ctx cancela", func(t *testing.T) {
tmpDir := t.TempDir()
ctx, cancel := context.WithCancel(context.Background())
ch, err := WatchDirFsnotify(ctx, tmpDir)
if err != nil {
t.Fatalf("WatchDirFsnotify: %v", err)
}
// Cancelar inmediatamente
cancel()
// El canal debe cerrarse
timeout := time.After(2 * time.Second)
// Drenar cualquier evento pendiente hasta que el canal se cierre
for {
select {
case _, ok := <-ch:
if !ok {
return // canal cerrado correctamente
}
case <-timeout:
t.Fatal("channel not closed after ctx cancel within 2s")
}
}
})
t.Run("error en directorio inexistente", func(t *testing.T) {
ctx := context.Background()
_, err := WatchDirFsnotify(ctx, "/nonexistent/dir/that/does/not/exist")
if err == nil {
t.Error("expected error for nonexistent directory")
}
})
t.Run("debounce agrupa multiples escrituras", func(t *testing.T) {
tmpDir := t.TempDir()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ch, err := WatchDirFsnotify(ctx, tmpDir)
if err != nil {
t.Fatalf("WatchDirFsnotify: %v", err)
}
time.Sleep(50 * time.Millisecond)
testFile := filepath.Join(tmpDir, "debounce.md")
// Escribir 5 veces rapidamente
for i := 0; i < 5; i++ {
_ = os.WriteFile(testFile, []byte("content"), 0644)
time.Sleep(10 * time.Millisecond)
}
// Esperar debounce + margen
time.Sleep(400 * time.Millisecond)
// Debe haber llegado al menos un evento pero no 5
eventCount := 0
drain:
for {
select {
case _, ok := <-ch:
if !ok {
break drain
}
eventCount++
default:
break drain
}
}
if eventCount == 0 {
t.Error("expected at least one debounced event")
}
if eventCount >= 5 {
t.Errorf("debounce failed: got %d events, expected fewer than 5", eventCount)
}
})
}
+33
View File
@@ -0,0 +1,33 @@
package infra
import (
"bytes"
"fmt"
"os"
"gopkg.in/yaml.v3"
)
// WriteIssueMd serializa el frontmatter del Issue a YAML y lo escribe en path junto al body.
// El archivo resultante tiene formato: "---\n<yaml>---\n<body>".
// El body se preserva exactamente tal como fue recibido (sin normalizar trailing newlines).
// Los campos de runtime (FilePath, MtimeNs, Completed) se omiten del YAML via yaml:"-".
func WriteIssueMd(path string, iss Issue, body []byte) error {
var buf bytes.Buffer
yamlBytes, err := yaml.Marshal(iss)
if err != nil {
return fmt.Errorf("write_issue_md: marshal %s: %w", path, err)
}
buf.WriteString("---\n")
buf.Write(yamlBytes)
buf.WriteString("---\n")
buf.Write(body)
if err := os.WriteFile(path, buf.Bytes(), 0644); err != nil {
return fmt.Errorf("write_issue_md: write %s: %w", path, err)
}
return nil
}
+57
View File
@@ -0,0 +1,57 @@
---
name: write_issue_md
kind: function
lang: go
domain: infra
version: "0.1.0"
purity: impure
signature: "func WriteIssueMd(path string, iss Issue, body []byte) error"
description: "Serializa el frontmatter de un struct Issue a YAML y escribe el archivo Markdown en disco con formato ---\\nyaml---\\nbody. Preserva el body exactamente sin normalizar trailing newlines ni reordenar. Los campos de runtime (FilePath, MtimeNs, Completed) se omiten del YAML via yaml:\"-\"."
tags: [issue, writer, frontmatter, yaml, dev-ux, kanban]
uses_functions: []
uses_types: [issue_go_infra]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["bytes", "fmt", "os", "gopkg.in/yaml.v3"]
params:
- name: path
desc: "Ruta de destino del archivo .md (puede ser la misma de la que se leyo para un update in-place)"
- name: iss
desc: "Struct Issue con el frontmatter a serializar. FilePath/MtimeNs/Completed se ignoran en el YAML de salida"
- name: body
desc: "Body MD tal como fue devuelto por ParseIssueMd — se escribe byte a byte sin modificar"
output: "nil en exito, error si el marshal YAML falla o el archivo no se puede escribir"
tested: true
tests:
- "round-trip parse-write-parse preserva struct"
- "archivo resultante empieza con ---"
- "error en path inexistente"
test_file_path: "functions/infra/write_issue_md_test.go"
file_path: "functions/infra/write_issue_md.go"
---
## Ejemplo
```go
// Actualizar status de un issue in-place
iss, body, err := infra.ParseIssueMd("dev/issues/0130-kanban-cpp-v2.md")
if err != nil { log.Fatal(err) }
iss.Status = "in-progress"
iss.Updated = "2026-05-22"
if err := infra.WriteIssueMd("dev/issues/0130-kanban-cpp-v2.md", iss, body); err != nil {
log.Fatal(err)
}
```
## Cuando usarla
Cuando el backend de kanban_cpp necesite actualizar el frontmatter de un issue (cambio de status, priority, tags, etc.) sin tocar el body. Siempre usar en par con `parse_issue_md_go_infra`: parse → modificar struct → write.
## Gotchas
- `yaml.Marshal` de v3 puede reordenar campos respecto al original — el orden del YAML de salida sera el orden de declaracion del struct `Issue`, no el del archivo original. Si el orden importa para diff legibilidad, documentarlo.
- El body se escribe byte a byte. Si lo modificas antes de pasar, lo que escribes es lo que queda.
- No hace backup previo. En sistemas con watcher activo, el write dispara un evento `write` en `watch_dir_fsnotify_go_infra` — el backend debe ignorar sus propios writes para no entrar en loop.
+92
View File
@@ -0,0 +1,92 @@
package infra
import (
"os"
"path/filepath"
"testing"
)
func TestWriteIssueMd(t *testing.T) {
root := registryRoot()
t.Run("round-trip parse-write-parse preserva struct", func(t *testing.T) {
fixturePath := filepath.Join(root, "functions", "infra", "testdata", "issue_fixture.fixture")
// Parse original
iss1, body1, err := ParseIssueMd(fixturePath)
if err != nil {
t.Fatalf("ParseIssueMd: %v", err)
}
// Write a TempDir
tmpPath := filepath.Join(t.TempDir(), "issue_roundtrip.md")
if err := WriteIssueMd(tmpPath, iss1, body1); err != nil {
t.Fatalf("WriteIssueMd: %v", err)
}
// Parse de nuevo
iss2, body2, err := ParseIssueMd(tmpPath)
if err != nil {
t.Fatalf("ParseIssueMd after write: %v", err)
}
// Comparar campos (ignorar FilePath y MtimeNs que son runtime)
if iss1.ID != iss2.ID {
t.Errorf("ID: %q != %q", iss1.ID, iss2.ID)
}
if iss1.Title != iss2.Title {
t.Errorf("Title: %q != %q", iss1.Title, iss2.Title)
}
if iss1.Status != iss2.Status {
t.Errorf("Status: %q != %q", iss1.Status, iss2.Status)
}
if iss1.Flow != iss2.Flow {
t.Errorf("Flow: %q != %q", iss1.Flow, iss2.Flow)
}
if len(iss1.Domain) != len(iss2.Domain) {
t.Errorf("Domain len: %d != %d", len(iss1.Domain), len(iss2.Domain))
}
if len(iss1.Depends) != len(iss2.Depends) {
t.Errorf("Depends len: %d != %d", len(iss1.Depends), len(iss2.Depends))
}
if len(iss1.Tags) != len(iss2.Tags) {
t.Errorf("Tags len: %d != %d", len(iss1.Tags), len(iss2.Tags))
}
// El body debe preservarse exactamente
if string(body1) != string(body2) {
t.Errorf("body mismatch:\ngot: %q\nwant: %q", string(body2), string(body1))
}
})
t.Run("archivo resultante empieza con ---", func(t *testing.T) {
iss := Issue{
ID: "0001",
Title: "Test issue",
Status: "pendiente",
}
tmpPath := filepath.Join(t.TempDir(), "test.md")
if err := WriteIssueMd(tmpPath, iss, []byte("# Body\n")); err != nil {
t.Fatalf("WriteIssueMd: %v", err)
}
data, _ := os.ReadFile(tmpPath)
if len(data) < 4 || string(data[:4]) != "---\n" {
t.Errorf("file should start with '---\\n', got: %q", string(data[:min(10, len(data))]))
}
})
t.Run("error en path inexistente", func(t *testing.T) {
iss := Issue{ID: "0001", Title: "x", Status: "pendiente"}
err := WriteIssueMd("/nonexistent/dir/issue.md", iss, []byte("body"))
if err == nil {
t.Error("expected error writing to nonexistent dir")
}
})
}
func min(a, b int) int {
if a < b {
return a
}
return b
}