From c468b24d2bd9f83b88bccf39e981c0aa11d7d461 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Fri, 22 May 2026 22:20:15 +0200 Subject: [PATCH] =?UTF-8?q?feat(0130):=20kanban=5Fcpp=20v2=20=E2=80=94=20b?= =?UTF-8?q?ackend=20Go=20+=205=20registry=20parser=20fns=20+=20epic/sub-is?= =?UTF-8?q?sues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- cpp/CMakeLists.txt | 6 + dev/issues/0130-kanban-cpp-v2.md | 24 ++-- dev/issues/0130a-kanban-cpp-v2-parser.md | 75 ++++++++++ dev/issues/0130b-kanban-cpp-v2-backend.md | 114 +++++++++++++++ dev/issues/0130c-kanban-cpp-v2-frontend.md | 86 +++++++++++ docs/capabilities/INDEX.md | 1 + docs/capabilities/kanban.md | 68 +++++++++ functions/infra/flow_type.go | 19 +++ functions/infra/fs_event_type.go | 8 ++ functions/infra/issue_type.go | 25 ++++ functions/infra/parse_issue_md.go | 87 +++++++++++ functions/infra/parse_issue_md.md | 51 +++++++ functions/infra/parse_issue_md_test.go | 101 +++++++++++++ functions/infra/scan_flows_dir.go | 83 +++++++++++ functions/infra/scan_flows_dir.md | 52 +++++++ functions/infra/scan_flows_dir_test.go | 66 +++++++++ functions/infra/scan_issues_dir.go | 62 ++++++++ functions/infra/scan_issues_dir.md | 54 +++++++ functions/infra/scan_issues_dir_test.go | 74 ++++++++++ .../infra/testdata/issue_fixture.fixture | 30 ++++ functions/infra/watch_dir_fsnotify.go | 135 ++++++++++++++++++ functions/infra/watch_dir_fsnotify.md | 61 ++++++++ functions/infra/watch_dir_fsnotify_test.go | 129 +++++++++++++++++ functions/infra/write_issue_md.go | 33 +++++ functions/infra/write_issue_md.md | 57 ++++++++ functions/infra/write_issue_md_test.go | 92 ++++++++++++ go.mod | 1 + go.sum | 2 + types/infra/flow_go_infra.md | 38 +++++ types/infra/fs_event_go_infra.md | 30 ++++ types/infra/issue_go_infra.md | 51 +++++++ 31 files changed, 1706 insertions(+), 9 deletions(-) create mode 100644 dev/issues/0130a-kanban-cpp-v2-parser.md create mode 100644 dev/issues/0130b-kanban-cpp-v2-backend.md create mode 100644 dev/issues/0130c-kanban-cpp-v2-frontend.md create mode 100644 docs/capabilities/kanban.md create mode 100644 functions/infra/flow_type.go create mode 100644 functions/infra/fs_event_type.go create mode 100644 functions/infra/issue_type.go create mode 100644 functions/infra/parse_issue_md.go create mode 100644 functions/infra/parse_issue_md.md create mode 100644 functions/infra/parse_issue_md_test.go create mode 100644 functions/infra/scan_flows_dir.go create mode 100644 functions/infra/scan_flows_dir.md create mode 100644 functions/infra/scan_flows_dir_test.go create mode 100644 functions/infra/scan_issues_dir.go create mode 100644 functions/infra/scan_issues_dir.md create mode 100644 functions/infra/scan_issues_dir_test.go create mode 100644 functions/infra/testdata/issue_fixture.fixture create mode 100644 functions/infra/watch_dir_fsnotify.go create mode 100644 functions/infra/watch_dir_fsnotify.md create mode 100644 functions/infra/watch_dir_fsnotify_test.go create mode 100644 functions/infra/write_issue_md.go create mode 100644 functions/infra/write_issue_md.md create mode 100644 functions/infra/write_issue_md_test.go create mode 100644 types/infra/flow_go_infra.md create mode 100644 types/infra/fs_event_go_infra.md create mode 100644 types/infra/issue_go_infra.md diff --git a/cpp/CMakeLists.txt b/cpp/CMakeLists.txt index fcf6de78..231e0e9b 100644 --- a/cpp/CMakeLists.txt +++ b/cpp/CMakeLists.txt @@ -535,3 +535,9 @@ set(_AGENTS_DASHBOARD_DIR ${CMAKE_SOURCE_DIR}/../projects/element_agents/apps/ag if(EXISTS ${_AGENTS_DASHBOARD_DIR}/CMakeLists.txt) add_subdirectory(${_AGENTS_DASHBOARD_DIR} ${CMAKE_BINARY_DIR}/apps/agents_dashboard) endif() + +# --- kanban_cpp (lives in apps/, issue 0096) --- +set(_KANBAN_CPP_DIR ${CMAKE_SOURCE_DIR}/../apps/kanban_cpp) +if(EXISTS ${_KANBAN_CPP_DIR}/CMakeLists.txt) + add_subdirectory(${_KANBAN_CPP_DIR} ${CMAKE_BINARY_DIR}/apps/kanban_cpp) +endif() diff --git a/dev/issues/0130-kanban-cpp-v2.md b/dev/issues/0130-kanban-cpp-v2.md index bc394eaf..4e39cdda 100644 --- a/dev/issues/0130-kanban-cpp-v2.md +++ b/dev/issues/0130-kanban-cpp-v2.md @@ -1,22 +1,28 @@ --- id: "0130" -title: "Kanban C++ v2 — gestor de dev/issues y dev/flows con backend Go + frontend ImGui" +title: Kanban C++ v2 — gestor de dev/issues y dev/flows con backend Go + frontend ImGui status: pendiente type: epic domain: - - cpp-stack - - apps-infra - - dev-ux + - cpp-stack + - apps-infra + - dev-ux scope: multi-app priority: alta depends: [] blocks: [] related: - - "0112" - - "0119" -created: 2026-05-22 -updated: 2026-05-22 -tags: [kanban, cpp, imgui, dev_ux, issues, flows] + - "0112" + - "0119" +tags: + - kanban + - cpp + - imgui + - dev_ux + - issues + - flows +created: "2026-05-22" +updated: "2026-05-22" --- # 0130 — Kanban C++ v2 diff --git a/dev/issues/0130a-kanban-cpp-v2-parser.md b/dev/issues/0130a-kanban-cpp-v2-parser.md new file mode 100644 index 00000000..97d3c93f --- /dev/null +++ b/dev/issues/0130a-kanban-cpp-v2-parser.md @@ -0,0 +1,75 @@ +--- +id: 0130a +title: 'Funciones registry: parser MD + scan dirs + writer + watcher' +status: pendiente +type: infra +domain: + - registry-quality + - dev-ux +scope: registry-only +priority: alta +depends: [] +blocks: + - 0130b +related: + - "0130" +tags: + - registry + - go + - parser + - frontmatter + - fsnotify +flow: "0130" +created: "2026-05-22" +updated: "2026-05-22" +--- + +# 0130a — Funciones registry para kanban_cpp v2 + +**Status:** pendiente + +## Por que + +El backend de kanban_cpp v2 necesita parsear/escribir frontmatter YAML de los `.md` de `dev/issues/` y `dev/flows/`. Estas piezas son reusables (cualquier app del registry puede operar sobre issues/flows), asi que viven en el registry, no en el backend de la app. + +## Funciones a crear (delegar a fn-constructor en paralelo) + +| ID | Firma | Pureza | +|---|---|---| +| `parse_issue_md_go_infra` | `(path string) (Issue, []byte body, error)` | impure (FS) | +| `write_issue_md_go_infra` | `(path string, issue Issue, body []byte) error` | impure (FS) | +| `scan_issues_dir_go_infra` | `(root string) ([]Issue, error)` | impure (FS) | +| `scan_flows_dir_go_infra` | `(root string) ([]Flow, error)` | impure (FS) | +| `watch_dir_fsnotify_go_infra` | `(ctx, root) (<-chan FsEvent, error)` | impure (FS, async) | + +Tipos: +- `Issue_go_infra` — struct con campos del frontmatter (id, title, status, type, domain, scope, priority, depends, blocks, related, flow, tags, created, updated, file_path, mtime_ns). +- `Flow_go_infra` — struct equivalente para flows. +- `FsEvent_go_infra` — `{path, op}` con `op in {create, write, remove, rename}`. + +## Notas de implementacion + +- Usar `gopkg.in/yaml.v3` para parsing (preserva orden de keys via `yaml.Node`). +- Writer DEBE preservar: + - Orden de campos del frontmatter original. + - Body MD intacto (todo lo que va despues del segundo `---`). + - Comentarios YAML (libre, best-effort). +- `parse_issue_md` debe ser tolerante: si falta un campo opcional, default empty. +- `watch_dir_fsnotify` recursivo, debounce 200ms. + +## DoD + +- 5 pares `.go` + `.md` en `functions/infra/`. +- Tests unitarios: + - parse → write → parse round-trip preserva struct. + - scan_issues_dir devuelve >=90 issues actuales. + - watcher detecta creacion + modificacion + borrado. +- `fn index` registra los 5 IDs + 3 tipos. +- `fn doctor uses-functions` limpio. + +## Anti-scope + +NO incluye en esta tanda: +- Markdown rendering del body (eso lo hace el frontend si quiere). +- Validacion contra TAXONOMY (existe `fn doctor issues`). +- CRUD de issues nuevos (write_issue cubre el caso, pero crear file = scope del backend). diff --git a/dev/issues/0130b-kanban-cpp-v2-backend.md b/dev/issues/0130b-kanban-cpp-v2-backend.md new file mode 100644 index 00000000..e8bcecd8 --- /dev/null +++ b/dev/issues/0130b-kanban-cpp-v2-backend.md @@ -0,0 +1,114 @@ +--- +id: "0130b" +title: "Backend Go kanban_cpp v2: schema + handlers + watcher + SSE" +status: pendiente +type: app +domain: + - apps-infra + - dev-ux +scope: app-scoped +priority: alta +depends: + - "0130a" +blocks: + - "0130c" +related: + - "0130" +created: 2026-05-22 +updated: 2026-05-22 +tags: [service, kanban, go, sqlite, sse] +flow: "0130" +--- + +# 0130b — Backend Go kanban_cpp v2 + +**Status:** pendiente + +## Por que + +Servicio HTTP local que sirve los issues + flows del proyecto al frontend C++. Es un wrapper fino sobre las funciones del registry de 0130a + SQLite cache + watcher. + +## Estructura + +``` +apps/kanban_cpp/backend/ + app.md # tag service + go.mod + main.go # entry: flags + run + db.go # open + apply migrations + upsert helpers + handlers.go # endpoints REST + sse_hub.go # broadcaster + watcher.go # bind a watch_dir_fsnotify + re-ingesta + emit SSE + ingest.go # scan → upsert; usa 0130a + migrations/ + 001_init.sql + operations.db # creada en runtime +``` + +## Endpoints + +| Verbo | Path | Notas | +|---|---|---| +| GET | `/api/health` | `{ok:true, version, count_issues, count_flows}` | +| GET | `/api/issues` | filtros: `status`, `domain`, `priority`, `tag`, `scope` | +| GET | `/api/issues/{id}` | issue + body | +| PATCH | `/api/issues/{id}` | partial update frontmatter → `write_issue_md` + re-ingesta + SSE | +| GET | `/api/flows` | filtros: `status`, `kind` | +| GET | `/api/flows/{id}` | flow + body | +| GET | `/api/meta` | enums leidos de `dev/TAXONOMY.md` | +| GET | `/api/sse` | stream `{type, id, path}` | + +CORS abierto local (`*`). Logger middleware. + +## Schema (migrations/001_init.sql) + +```sql +CREATE TABLE IF NOT EXISTS issues ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + status TEXT NOT NULL, + type TEXT, + scope TEXT, + priority TEXT, + domain_json TEXT NOT NULL DEFAULT '[]', + tags_json TEXT NOT NULL DEFAULT '[]', + depends_json TEXT NOT NULL DEFAULT '[]', + blocks_json TEXT NOT NULL DEFAULT '[]', + related_json TEXT NOT NULL DEFAULT '[]', + flow_id TEXT, + body TEXT NOT NULL DEFAULT '', + file_path TEXT NOT NULL, + mtime_ns INTEGER NOT NULL, + created_at TEXT, + updated_at TEXT, + completed INTEGER NOT NULL DEFAULT 0 -- 1 si vive en completed/ +); +CREATE INDEX IF NOT EXISTS idx_issues_status ON issues(status); +CREATE INDEX IF NOT EXISTS idx_issues_priority ON issues(priority); + +CREATE TABLE IF NOT EXISTS flows ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + status TEXT, + kind TEXT, + tags_json TEXT NOT NULL DEFAULT '[]', + body TEXT NOT NULL DEFAULT '', + file_path TEXT NOT NULL, + mtime_ns INTEGER NOT NULL +); +``` + +## DoD + +- `curl http://localhost:8487/api/health` devuelve 200 + counts. +- `curl http://localhost:8487/api/issues | jq 'length' >= 90`. +- `curl -X PATCH /api/issues/0130 -d '{"status":"in-progress"}'` reescribe `dev/issues/0130-*.md` (status updated, body intacto). +- Despues del PATCH, suscriptor SSE recibe evento `{type:"updated", id:"0130"}`. +- Tras `mv dev/issues/0130-*.md dev/issues/completed/`, watcher actualiza fila (`completed=1`). +- `go test ./...` verde. + +## Anti-scope + +- No expone proposals ni capabilities (eso es MCP registry). +- No autentica (local-only por ahora). +- No persiste estado UI (eso lo hace el frontend). diff --git a/dev/issues/0130c-kanban-cpp-v2-frontend.md b/dev/issues/0130c-kanban-cpp-v2-frontend.md new file mode 100644 index 00000000..793bd1a5 --- /dev/null +++ b/dev/issues/0130c-kanban-cpp-v2-frontend.md @@ -0,0 +1,86 @@ +--- +id: "0130c" +title: "Frontend C++ ImGui kanban_cpp v2: board + flows + filtros + detalle" +status: pendiente +type: app +domain: + - cpp-stack + - dev-ux +scope: app-scoped +priority: alta +depends: + - "0130b" +blocks: [] +related: + - "0130" +created: 2026-05-22 +updated: 2026-05-22 +tags: [cpp, imgui, kanban, frontend] +flow: "0130" +--- + +# 0130c — Frontend C++ ImGui kanban_cpp v2 + +**Status:** pendiente + +## Por que + +UI nativa sobre el backend 0130b. Aprovecha el framework `fn::run_app` (menubar, layouts, settings, about, log) y los componentes del registry (`data_table`, `kpi_card`, `http_request`, `sse_client`). + +## Estructura + +``` +apps/kanban_cpp/ + app.md + appicon.ico + CMakeLists.txt + main.cpp # fn::run_app + cfg.panels + data.h / data.cpp # http client + state global (issues, flows, filters) + panel_board.cpp # 4 columnas + drag-drop + panel_flows.cpp # tabla via data_table_cpp_viz + panel_filters.cpp # Aside con multi-select + panel_detail.cpp # form editable del issue seleccionado + panels.h +``` + +## Trio obligatorio (`app.md`) + +```yaml +description: "Kanban C++ v2 para gestionar dev/issues y dev/flows del registry" +icon: + phosphor: "kanban" + accent: "#a855f7" +``` + +## Paneles + +1. **Board** (`TI_KANBAN " Board"`) — 4 columnas (pendiente / in-progress / bloqueado / completado). Cada card: id + title (trunc 60) + priority badge + first domain chip. Drag-drop con `ImGui::BeginDragDropSource/Target` -> PATCH status. +2. **Flows** (`TI_FLOW " Flows"`) — `data_table_cpp_viz` con columnas id/title/status/kind. Click fila → carga detail. +3. **Filters** (`TI_FUNNEL " Filters"`) — AppShell.Aside-equivalente (panel lateral fijo). Multi-select por domain, scope, priority, tags. Estado local; rebuild request query. +4. **Detail** (`TI_INFO " Detail"`) — modal/panel lateral con form: status (combo), priority (combo), scope (combo), tags (chips editables), depends/blocks (listas), body (read-only multiline). + +## HTTP client (data.cpp) + +- `fetch_issues(filters)` → GET con query string → parse JSON → vector. +- `fetch_flows()` → similar. +- `patch_issue(id, partial)` → PATCH JSON → recibe issue actualizado. +- `subscribe_sse()` thread aparte → push events a queue mutex → consumir en main loop → re-fetch afectados. + +Usa `http_request_cpp_core` + `sse_client_cpp_core`. JSON via `nlohmann/json` (ya en cpp/vendor o sacar al header-only). + +## DoD + +- `cmake --build cpp/build/linux --target kanban_cpp -j` verde. +- `./cpp/build/linux/apps/kanban_cpp/kanban_cpp --self-test` exit 0: + - inicializa contexto ImGui sin display. + - parsea respuesta JSON sintetica. + - no toca red salvo si `--backend http://...` se pasa. +- e2e_checks en `app.md`: build + self_test + backend_health (corre backend en background) + smoke (drag-drop reescribe MD). +- Captura screenshot board con 4 columnas pobladas → guardar en `dod_evidence/board_screenshot.png`. + +## Anti-scope + +- Sin grafo de dependencias (epic 0130 lo describe como anti-scope v1). +- Sin crear issues nuevos (solo editar existentes). +- Sin edicion de body MD (solo frontmatter). +- Sin syntax highlighting markdown. diff --git a/docs/capabilities/INDEX.md b/docs/capabilities/INDEX.md index 17675375..35aab703 100644 --- a/docs/capabilities/INDEX.md +++ b/docs/capabilities/INDEX.md @@ -40,6 +40,7 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu | [cpp-dashboard-viz](cpp-dashboard-viz.md) | 10 | Primitivas C++ ImGui para dashboards: kpi_card, sparkline, line/bar/scatter/pie/heatmap/histogram, panel containers | | [agents](agents.md) | 3 | Orquestar agentes Claude headless en git worktrees: launch, cleanup, DoD evidence schema audit | | [backends](backends.md) | — | Stacks backend (Go net/http+SQLite default, MCP, mautrix, bubbletea, httpx, docker-compose): decision tree + esqueleto canonico + funciones del registry a componer | +| [kanban](kanban.md) | 5 | Parser/writer/scanner/watcher de dev/issues/ y dev/flows/: base del backend kanban_cpp v2 | ## Como anadir grupo diff --git a/docs/capabilities/kanban.md b/docs/capabilities/kanban.md new file mode 100644 index 00000000..462591f3 --- /dev/null +++ b/docs/capabilities/kanban.md @@ -0,0 +1,68 @@ +# kanban — Parser/writer de issues y flows del registry + +Cluster de funciones para leer, escribir y vigilar los archivos `dev/issues/*.md` y `dev/flows/*.md`. Base del backend de `kanban_cpp v2` (issue 0130b) y de cualquier herramienta que opere sobre el board de desarrollo. + +## Funciones + +| ID | Firma corta | Que hace | +|---|---|---| +| `parse_issue_md_go_infra` | `(path) → (Issue, []byte, error)` | Lee un .md de issue, extrae frontmatter YAML + body | +| `write_issue_md_go_infra` | `(path, Issue, body) → error` | Serializa Issue a YAML y reescribe el .md preservando body | +| `scan_issues_dir_go_infra` | `(root) → ([]Issue, error)` | Escanea dev/issues/ + completed/, devuelve todos los Issues ordenados | +| `scan_flows_dir_go_infra` | `(root) → ([]Flow, error)` | Escanea dev/flows/, devuelve todos los Flows ordenados | +| `watch_dir_fsnotify_go_infra` | `(ctx, root) → (<-chan FsEvent, error)` | Watcher recursivo con debounce 200ms, emite FsEvent por cambio | + +## Tipos + +| ID | Que es | +|---|---| +| `issue_go_infra` | Frontmatter de dev/issues/*.md: id, title, status, domain, priority, depends, blocks… | +| `flow_go_infra` | Frontmatter de dev/flows/*.md: id, name/title, status, kind, tags | +| `fs_event_go_infra` | Evento de watcher: {Path, Op} donde Op ∈ {create, write, remove, rename} | + +## Ejemplo canónico — arrancar el backend de kanban_cpp + +```go +import "fn-registry/functions/infra" + +const ( + issuesDir = "/home/lucas/fn_registry/dev/issues" + flowsDir = "/home/lucas/fn_registry/dev/flows" +) + +// 1. Carga inicial +issues, _ := infra.ScanIssuesDir(issuesDir) +flows, _ := infra.ScanFlowsDir(flowsDir) +fmt.Printf("%d issues, %d flows cargados\n", len(issues), len(flows)) + +// 2. Actualizar status in-place +iss, body, _ := infra.ParseIssueMd(issuesDir + "/0130-kanban-cpp-v2.md") +iss.Status = "in-progress" +iss.Updated = "2026-05-22" +infra.WriteIssueMd(iss.FilePath, iss, body) + +// 3. Vigilar cambios externos (editor de texto, otro agente) +ctx, cancel := context.WithCancel(context.Background()) +defer cancel() +ch, _ := infra.WatchDirFsnotify(ctx, issuesDir) +for ev := range ch { + if strings.HasSuffix(ev.Path, ".md") { + updated, _, _ := infra.ParseIssueMd(ev.Path) + cache.Upsert(updated) // invalidar cache SQLite + } +} +``` + +## Fronteras + +- NO incluye markdown rendering del body (eso lo hace el frontend). +- NO valida campos contra TAXONOMY (existe `fn doctor issues`). +- NO crea ni borra archivos de issue (solo lee/escribe los existentes). +- NO incluye endpoints HTTP ni SSE (eso es el backend de la app, issue 0130b). + +## Notas + +- `parse_issue_md` + `write_issue_md` son el par CRUD atómico. Siempre usarlos juntos. +- `scan_issues_dir` llama a `parse_issue_md` internamente — no reimplementar el walk. +- `watch_dir_fsnotify` emite eventos para cualquier archivo, no solo `.md`. Filtrar por extensión en el consumidor. +- El watcher y el writer pueden producir loops: el writer dispara un evento `write` que el watcher emite. El backend debe ignorar eventos generados por sus propios writes (comparar path + timestamp). diff --git a/functions/infra/flow_type.go b/functions/infra/flow_type.go new file mode 100644 index 00000000..bc8a55e2 --- /dev/null +++ b/functions/infra/flow_type.go @@ -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:"-"` +} diff --git a/functions/infra/fs_event_type.go b/functions/infra/fs_event_type.go new file mode 100644 index 00000000..44557d52 --- /dev/null +++ b/functions/infra/fs_event_type.go @@ -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" +} diff --git a/functions/infra/issue_type.go b/functions/infra/issue_type.go new file mode 100644 index 00000000..3d2fa6dc --- /dev/null +++ b/functions/infra/issue_type.go @@ -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/ +} diff --git a/functions/infra/parse_issue_md.go b/functions/infra/parse_issue_md.go new file mode 100644 index 00000000..8068bc2e --- /dev/null +++ b/functions/infra/parse_issue_md.go @@ -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\n---\n". +// 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 +} diff --git a/functions/infra/parse_issue_md.md b/functions/infra/parse_issue_md.md new file mode 100644 index 00000000..0694b08b --- /dev/null +++ b/functions/infra/parse_issue_md.md @@ -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. diff --git a/functions/infra/parse_issue_md_test.go b/functions/infra/parse_issue_md_test.go new file mode 100644 index 00000000..33ad6642 --- /dev/null +++ b/functions/infra/parse_issue_md_test.go @@ -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)) + } + }) +} diff --git a/functions/infra/scan_flows_dir.go b/functions/infra/scan_flows_dir.go new file mode 100644 index 00000000..9364be4c --- /dev/null +++ b/functions/infra/scan_flows_dir.go @@ -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 +} diff --git a/functions/infra/scan_flows_dir.md b/functions/infra/scan_flows_dir.md new file mode 100644 index 00000000..4abba8a9 --- /dev/null +++ b/functions/infra/scan_flows_dir.md @@ -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. diff --git a/functions/infra/scan_flows_dir_test.go b/functions/infra/scan_flows_dir_test.go new file mode 100644 index 00000000..f00c5156 --- /dev/null +++ b/functions/infra/scan_flows_dir_test.go @@ -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 + } + } + }) +} diff --git a/functions/infra/scan_issues_dir.go b/functions/infra/scan_issues_dir.go new file mode 100644 index 00000000..5ad885f2 --- /dev/null +++ b/functions/infra/scan_issues_dir.go @@ -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 +} diff --git a/functions/infra/scan_issues_dir.md b/functions/infra/scan_issues_dir.md new file mode 100644 index 00000000..a7e14021 --- /dev/null +++ b/functions/infra/scan_issues_dir.md @@ -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`. diff --git a/functions/infra/scan_issues_dir_test.go b/functions/infra/scan_issues_dir_test.go new file mode 100644 index 00000000..886a9772 --- /dev/null +++ b/functions/infra/scan_issues_dir_test.go @@ -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") + } + }) +} diff --git a/functions/infra/testdata/issue_fixture.fixture b/functions/infra/testdata/issue_fixture.fixture new file mode 100644 index 00000000..31489808 --- /dev/null +++ b/functions/infra/testdata/issue_fixture.fixture @@ -0,0 +1,30 @@ +--- +id: "9999" +title: "Fixture issue con caracteres especiales: áéíóú & " +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: áéíóú & . + +## Sección + +Linea con **negrita** y _cursiva_. + +Final del body. diff --git a/functions/infra/watch_dir_fsnotify.go b/functions/infra/watch_dir_fsnotify.go new file mode 100644 index 00000000..2f6e7d13 --- /dev/null +++ b/functions/infra/watch_dir_fsnotify.go @@ -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 "" + } +} diff --git a/functions/infra/watch_dir_fsnotify.md b/functions/infra/watch_dir_fsnotify.md new file mode 100644 index 00000000..26b90206 --- /dev/null +++ b/functions/infra/watch_dir_fsnotify.md @@ -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. diff --git a/functions/infra/watch_dir_fsnotify_test.go b/functions/infra/watch_dir_fsnotify_test.go new file mode 100644 index 00000000..8296cb51 --- /dev/null +++ b/functions/infra/watch_dir_fsnotify_test.go @@ -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) + } + }) +} diff --git a/functions/infra/write_issue_md.go b/functions/infra/write_issue_md.go new file mode 100644 index 00000000..d37e3eef --- /dev/null +++ b/functions/infra/write_issue_md.go @@ -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---\n". +// 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 +} diff --git a/functions/infra/write_issue_md.md b/functions/infra/write_issue_md.md new file mode 100644 index 00000000..84cde61c --- /dev/null +++ b/functions/infra/write_issue_md.md @@ -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. diff --git a/functions/infra/write_issue_md_test.go b/functions/infra/write_issue_md_test.go new file mode 100644 index 00000000..c7406feb --- /dev/null +++ b/functions/infra/write_issue_md_test.go @@ -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 +} diff --git a/go.mod b/go.mod index 966c30a6..d76639b5 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 + github.com/fsnotify/fsnotify v1.7.0 github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.9.1 github.com/marcboeker/go-duckdb v1.8.5 diff --git a/go.sum b/go.sum index b1c3dc34..b6fd919b 100644 --- a/go.sum +++ b/go.sum @@ -39,6 +39,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw= github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= diff --git a/types/infra/flow_go_infra.md b/types/infra/flow_go_infra.md new file mode 100644 index 00000000..32c226d6 --- /dev/null +++ b/types/infra/flow_go_infra.md @@ -0,0 +1,38 @@ +--- +name: flow +lang: go +domain: infra +version: "0.1.0" +algebraic: product +definition: | + 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"` + Name string `yaml:"name,omitempty"` + Priority string `yaml:"priority,omitempty"` + FilePath string `yaml:"-"` + MtimeNs int64 `yaml:"-"` + } +description: "Frontmatter YAML de un archivo dev/flows/*.md. Campos de runtime (FilePath, MtimeNs) no se serializan en YAML." +tags: [flow, frontmatter, yaml, kanban, dev-ux, registry] +uses_types: [] +file_path: "functions/infra/flow_type.go" +--- + +## Ejemplo + +```go +f := infra.Flow{ + ID: "0001", + Name: "hn-top-stories", + Status: "pending", + Tags: []string{"scraping", "news"}, +} +``` + +## Notas + +Producido por `scan_flows_dir_go_infra`. Los flows del registry usan campos variados en su frontmatter — el struct cubre el subconjunto comun: id/name/title/status/kind/tags/priority. Campos desconocidos se ignoran silenciosamente por yaml.Unmarshal. diff --git a/types/infra/fs_event_go_infra.md b/types/infra/fs_event_go_infra.md new file mode 100644 index 00000000..eb4c7efb --- /dev/null +++ b/types/infra/fs_event_go_infra.md @@ -0,0 +1,30 @@ +--- +name: fs_event +lang: go +domain: infra +version: "0.1.0" +algebraic: product +definition: | + type FsEvent struct { + Path string + Op string // "create" | "write" | "remove" | "rename" + } +description: "Evento del watcher de sistema de archivos. Op es uno de: create, write, remove, rename." +tags: [watcher, fsnotify, event, filesystem, kanban, dev-ux] +uses_types: [] +file_path: "functions/infra/fs_event_type.go" +--- + +## Ejemplo + +```go +// Recibido desde el canal de watch_dir_fsnotify_go_infra: +ev := infra.FsEvent{ + Path: "/home/lucas/fn_registry/dev/issues/0130a-kanban-cpp-v2-parser.md", + Op: "write", +} +``` + +## Notas + +Producido por `watch_dir_fsnotify_go_infra`. El canal emite un evento por archivo afectado tras el debounce de 200ms. Si se producen multiples operaciones sobre el mismo path en la ventana de debounce, se emite solo la ultima operacion. diff --git a/types/infra/issue_go_infra.md b/types/infra/issue_go_infra.md new file mode 100644 index 00000000..afdd9f89 --- /dev/null +++ b/types/infra/issue_go_infra.md @@ -0,0 +1,51 @@ +--- +name: issue +lang: go +domain: infra +version: "0.1.0" +algebraic: product +definition: | + 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"` + FilePath string `yaml:"-"` + MtimeNs int64 `yaml:"-"` + Completed bool `yaml:"-"` + } +description: "Frontmatter YAML de un archivo dev/issues/*.md. Campos de runtime (FilePath, MtimeNs, Completed) no se serializan en YAML." +tags: [issue, frontmatter, yaml, kanban, dev-ux, registry] +uses_types: [] +file_path: "functions/infra/issue_type.go" +--- + +## Ejemplo + +```go +iss := infra.Issue{ + ID: "0130", + Title: "Kanban C++ v2", + Status: "pendiente", + Priority: "alta", + Domain: []string{"cpp-stack", "apps-infra"}, + Scope: "multi-app", + Tags: []string{"kanban", "cpp"}, + Created: "2026-05-22", + Updated: "2026-05-22", +} +``` + +## Notas + +Producido por `parse_issue_md_go_infra`. Los campos `Depends`, `Blocks`, `Related`, `Tags`, `Domain` se deserializan como `[]string` — si el YAML los omite, quedan como slice vacio (no nil). `Completed` se deduce del path (contiene `/completed/`), no del frontmatter.