chore: snapshot WIP previo + flow 0008 + 7 sub-issues (0112-0119)

Snapshot de WIP acumulado de sesiones previas antes de merge wave 1
del flow 0008 (kanban_cpp + agent_runner_api + DoD schema).

Incluye:
- dev/flows/0008-kanban-cpp-and-agent-workflows.md
- dev/issues/0112-0119*.md (7 sub-issues)
- WIP previo en cmd/fn/doctor.go, registry/*, modules/, cpp/, etc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 18:17:08 +02:00
parent ddb5366884
commit b9716a7cd6
119 changed files with 14929 additions and 3084 deletions
+3 -9
View File
@@ -60,16 +60,10 @@ void about_window_render() {
ImGui::TextWrapped("%s", g_description.c_str());
}
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
// --- Framework version (issue 0097) ---
ImGui::Text("Framework");
ImGui::SameLine();
ImGui::TextDisabled("v%s", fn::framework_version());
// --- Modules consumidos por la app (issue 0097) ---
// codegen_app_modules.py auto-prepende `framework_cpp` a uses_modules de
// toda app C++, asi que la tabla Modules SIEMPRE lista al framework con
// su version + cada modulo declarado en app.md::uses_modules.
if (fn::app_modules_count > 0) {
ImGui::Spacing();
ImGui::Separator();
@@ -2,6 +2,7 @@
// Promovido del playground tables (issue 0081-F). Pura — sin I/O ni estado.
#include "core/compute_column_stats.h"
#include "core/auto_detect_type.h" // parse_number
#include <algorithm>
#include <cmath>
+4 -1
View File
@@ -2,7 +2,10 @@
// Promovido del playground tables (issue 0081-F). Pura — sin I/O ni estado.
#pragma once
#include "core/auto_detect_type.h" // parse_number reutilizado en la impl
// NOTE: auto_detect_type.h (parse_number) is required by the .cpp impl only.
// It is NOT included here because data_table_types.h includes this header,
// and auto_detect_type.h itself references ColumnType (defined in
// data_table_types.h) — that would create a circular include.
#include <string>
#include <utility>
+210
View File
@@ -0,0 +1,210 @@
#include "core/compute_ring_layout.h"
#include <algorithm>
#include <cmath>
#include <functional> // std::hash
#include <unordered_map>
namespace fn_ring {
// ---------------------------------------------------------------------------
// Constantes internas
// ---------------------------------------------------------------------------
static constexpr float kPi = 3.14159265358979323846f;
static constexpr float kTwoPi = 2.0f * kPi;
// Radio minimo para ring 0 cuando ring_radii[0]==0 (evita colocar nodos en el
// origen exacto, reservado para HUD).
static constexpr float kRing0InnerMin = 30.0f;
// Umbral: si el bin tiene mas nodos de los que caben radialmente con separacion
// minima MIN_RADIAL_SPACING, activamos jitter angular.
static constexpr float kMinRadialSpacing = 18.0f;
// Amplitud maxima del jitter angular: ±0.4 del half-sector (se mantiene dentro
// del sector para no solapar el vecino).
static constexpr float kJitterMaxFraction = 0.4f;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Hash deterministico FNV-1a 32 bits (estable cross-platform, sin depender de
/// std::hash<string> que puede variar entre ABI/compiladores).
static uint32_t fnv1a_32(const std::string& s) {
uint32_t h = 2166136261u;
for (unsigned char c : s) {
h ^= c;
h *= 16777619u;
}
return h;
}
/// Devuelve valor deterministico en [-1.0, +1.0] usando el hash del id.
static float id_jitter(const std::string& id) {
uint32_t h = fnv1a_32(id);
// Normalizar a [-1, +1]
float v = static_cast<float>(h) / static_cast<float>(0xFFFFFFFFu); // [0,1]
return v * 2.0f - 1.0f; // [-1,+1]
}
/// Devuelve el mapa canonico de status → ring.
static StatusRingMap default_status_map() {
return {
{"completado", 0},
{"completed", 0},
{"in-progress", 1},
{"pendiente_unlocked", 2},
{"unlocked", 2},
{"pending", 2},
{"pendiente", 3},
{"locked", 3},
{"deferred", 4},
{"bloqueado", 4},
};
}
/// Busca status en el mapa. Retorna -1 si no encontrado.
static int lookup_ring(const std::string& status, const StatusRingMap& smap) {
for (auto& [k, v] : smap) {
if (k == status) return v;
}
return -1;
}
/// Busca domain en el orden. Retorna n_sectors-1 si no encontrado.
static int lookup_sector(const std::string& domain,
const DomainOrder& order,
int n_sectors) {
for (int i = 0; i < static_cast<int>(order.size()); ++i) {
if (order[i] == domain) return i;
}
return n_sectors - 1;
}
// ---------------------------------------------------------------------------
// Implementacion principal
// ---------------------------------------------------------------------------
std::vector<LayoutOutput>
compute_ring_layout(const std::vector<LayoutInput>& nodes,
const LayoutConfig& cfg,
const StatusRingMap& status_map_in,
const DomainOrder& domain_order) {
if (nodes.empty()) return {};
const StatusRingMap& smap = status_map_in.empty()
? default_status_map()
: status_map_in;
const int n_rings = static_cast<int>(cfg.ring_radii.size()) - 1;
const int n_sectors = cfg.n_sectors > 0 ? cfg.n_sectors : 18;
const float sector_angle = kTwoPi / static_cast<float>(n_sectors);
const float half_sector = sector_angle * 0.5f;
// Preasignar outputs con ring=-1 (descartados por defecto)
std::vector<LayoutOutput> out(nodes.size());
for (size_t i = 0; i < nodes.size(); ++i) {
out[i].id = nodes[i].id;
}
// --- Paso 1: mapear cada nodo a (ring, sector) ---
// indices_in_bin[ring][sector] = lista de indices en nodes[]
using BinKey = std::pair<int,int>;
struct BinKeyHash {
size_t operator()(const BinKey& k) const noexcept {
return std::hash<int>()(k.first) ^ (std::hash<int>()(k.second) << 16);
}
};
std::unordered_map<BinKey, std::vector<size_t>, BinKeyHash> bins;
for (size_t i = 0; i < nodes.size(); ++i) {
int ring = lookup_ring(nodes[i].status, smap);
if (ring < 0 || ring >= n_rings) {
// Nodo descartado: ring=-1, x/y=0 (ya inicializado)
continue;
}
int sector = lookup_sector(nodes[i].domain, domain_order, n_sectors);
out[i].ring = ring;
out[i].sector = sector;
bins[{ring, sector}].push_back(i);
}
// --- Paso 2: posicionar nodos dentro de cada bin ---
for (auto& [key, indices] : bins) {
const int ring = key.first;
const int sector = key.second;
// Ordenar bin: recency desc, id asc (deterministico)
std::sort(indices.begin(), indices.end(),
[&](size_t a, size_t b) {
float ra = nodes[a].recency;
float rb = nodes[b].recency;
if (ra != rb) return ra > rb;
return nodes[a].id < nodes[b].id;
});
// Asignar rank_in_bin
for (int rank = 0; rank < static_cast<int>(indices.size()); ++rank) {
out[indices[rank]].rank_in_bin = rank;
}
// Radio interior y exterior del ring
float r_inner = cfg.ring_radii[ring];
float r_outer = cfg.ring_radii[ring + 1];
// Caso especial: ring 0 con radio interno == 0
if (r_inner == 0.0f) r_inner = kRing0InnerMin;
// Aplicar padding
float r_lo = r_inner + cfg.bin_padding;
float r_hi = r_outer - cfg.bin_padding;
if (r_lo > r_hi) {
// Bin demasiado estrecho: usar el punto medio
r_lo = r_hi = (r_inner + r_outer) * 0.5f;
}
const int N = static_cast<int>(indices.size());
// Angulo central del sector (sin jitter)
float theta_center = cfg.start_angle
+ (static_cast<float>(sector) + 0.5f) * sector_angle;
// Determinar si activamos jitter angular (bin sobrecargado radialmente)
float band_height = r_hi - r_lo;
int radial_capacity = (band_height < 1.0f)
? 1
: std::max(1, static_cast<int>(band_height / kMinRadialSpacing));
bool use_jitter = (N > radial_capacity);
// Posicionar cada nodo
for (int rank = 0; rank < N; ++rank) {
size_t idx = indices[rank];
// Radio: distribucion uniforme en la banda
float r;
if (N == 1) {
r = (r_lo + r_hi) * 0.5f;
} else {
r = r_lo + (static_cast<float>(rank) + 0.5f) * (r_hi - r_lo)
/ static_cast<float>(N);
}
// Sub-jitter angular deterministico si bin sobrecargado
float jitter = 0.0f;
if (use_jitter) {
float raw = id_jitter(nodes[idx].id); // en [-1,+1]
jitter = raw * half_sector * kJitterMaxFraction;
}
float theta = theta_center + jitter;
out[idx].x = cfg.center_x + r * std::cos(theta);
out[idx].y = cfg.center_y + r * std::sin(theta);
}
}
return out;
}
} // namespace fn_ring
+73
View File
@@ -0,0 +1,73 @@
#pragma once
#include <string>
#include <vector>
/// fn_ring — geometria pura para layout en anillos concentricos + sectores radiales.
/// Pure: sin I/O, sin estado global, sin RNG. Misma entrada → mismo output siempre.
/// C++17 STL only.
namespace fn_ring {
struct LayoutInput {
std::string id; // identidad unica para mapeo deterministico
std::string status; // categoria libre — mapeada al ring via status_map
std::string domain; // categoria libre — mapeada al sector via domain_order
float recency = 0.0f; // 0..1, ordena DENTRO de un (ring,sector) bin (desc)
};
struct LayoutOutput {
std::string id;
float x = 0.0f;
float y = 0.0f;
int ring = -1; // -1 = nodo descartado (status no mapeado)
int sector = 0;
int rank_in_bin = 0; // 0..N-1 dentro del bin (ring,sector)
};
struct LayoutConfig {
int n_sectors = 18;
float center_x = 0.0f;
float center_y = 0.0f;
// ring_radii[i] = radio interno del ring i
// ring_radii[i+1] = radio externo del ring i
// Default: 5 rings (0..4)
std::vector<float> ring_radii { 0.0f, 150.0f, 280.0f, 450.0f, 650.0f, 850.0f };
float bin_padding = 14.0f; // padding interior del bin en pixels
float start_angle = 0.0f; // rotacion global del sector 0 en radianes
};
/// Mapea strings de status a indice de ring.
/// Status no listado → ring -1 (nodo descartado).
///
/// Default canonico (usado cuando se pasa vector vacio):
/// {"completado",0}, {"completed",0},
/// {"in-progress",1},
/// {"pendiente_unlocked",2}, {"unlocked",2}, {"pending",2},
/// {"pendiente",3}, {"locked",3},
/// {"deferred",4}, {"bloqueado",4}
using StatusRingMap = std::vector<std::pair<std::string, int>>;
/// Mapea strings de domain a indice de sector [0..n_sectors-1].
/// Domains no listados → sector n_sectors-1 (sector "otros").
using DomainOrder = std::vector<std::string>;
/// Compute ring+sector layout deterministico.
///
/// Para cada nodo:
/// 1. ring = status_map[node.status]; descartar si -1.
/// 2. sector = index(domain_order, node.domain); fallback n_sectors-1.
/// 3. Bin (ring, sector): ordenar por (recency desc, id asc), distribuir
/// uniformemente en la banda radial con padding. Sub-jitter angular
/// deterministico via hash del id cuando el bin esta sobrecargado.
/// 4. theta = start_angle + (sector + 0.5) * (2*PI / n_sectors) + jitter
/// 5. x = center.x + r * cos(theta); y = center.y + r * sin(theta)
///
/// Ring 0 con radio interno 0: usa r_inner = 30.0f para no colocar nodos
/// en el origen exacto (reservado para HUD overlay).
std::vector<LayoutOutput>
compute_ring_layout(const std::vector<LayoutInput>& nodes,
const LayoutConfig& cfg,
const StatusRingMap& status_map = {},
const DomainOrder& domain_order = {});
} // namespace fn_ring
+90
View File
@@ -0,0 +1,90 @@
---
name: compute_ring_layout
kind: function
lang: cpp
domain: core
purity: pure
version: "1.0.0"
signature: "std::vector<LayoutOutput> compute_ring_layout(const std::vector<LayoutInput>& nodes, const LayoutConfig& cfg, const StatusRingMap& status_map, const DomainOrder& domain_order)"
description: "Calcula posiciones (x,y) deterministicas para layout en anillos concentricos por status + sectores radiales por domain. Pure, sin fisicas, output reproducible. Util para skill_tree, dashboards de roadmap, mapas de capability."
tags: [layout, rings, sectors, polar, dashboard, registry, skill-tree]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["core/compute_ring_layout.h"]
params:
- name: nodes
desc: "Vector de LayoutInput. Cada nodo tiene id (unico), status (bucket de ring), domain (sector), recency (0..1, ordena dentro del bin desc)."
- name: cfg
desc: "LayoutConfig: n_sectors, center_x/y, ring_radii (bordes de los anillos), bin_padding, start_angle. Defaults validos para 5 rings / 18 sectores / canvas 850px de radio."
- name: status_map
desc: "Mapa status→ring. Vector vacio usa el mapa canonico (completado→0, in-progress→1, unlocked→2, pendiente→3, deferred→4). Status no encontrado → ring=-1 (descartado)."
- name: domain_order
desc: "Lista ordenada de domains para asignar sector. Domain fuera de la lista → sector n_sectors-1 (sector 'otros'). Vector vacio → todos los domains caen en sector n_sectors-1."
output: "Vector de LayoutOutput en el mismo orden que nodes[]. Cada salida: id, x, y (posicion en world units), ring (-1=descartado), sector, rank_in_bin (orden dentro del (ring,sector))."
tested: true
tests:
- "empty_input_empty_output"
- "single_node_centered_in_bin"
- "two_nodes_same_bin_radial_distribution"
- "default_status_map"
- "unmapped_status_returns_ring_minus_one"
- "domain_not_in_order_falls_back_to_last_sector"
- "deterministic_repeated_call"
- "ring_zero_avoids_origin"
- "sector_wrap_around_last_sector"
- "golden_snapshot_30_nodes"
test_file_path: "cpp/tests/test_compute_ring_layout.cpp"
file_path: "cpp/functions/core/compute_ring_layout.cpp"
---
## Ejemplo
```cpp
#include "core/compute_ring_layout.h"
fn_ring::LayoutConfig cfg;
// cfg usa defaults: 5 rings, 18 sectors, ring_radii={0,150,280,450,650,850}
fn_ring::DomainOrder order = {"core", "infra", "finance", "datascience"};
std::vector<fn_ring::LayoutInput> nodes = {
{"0001", "completado", "core", 1.0f},
{"0002", "pendiente", "infra", 0.5f},
{"0003", "in-progress", "finance", 0.8f},
{"0004", "deferred", "core", 0.1f},
};
auto out = fn_ring::compute_ring_layout(nodes, cfg, {}, order);
// out[0]: ring=0, sector=0 (completado → ring0, core → sector0)
// out[1]: ring=3, sector=1 (pendiente → ring3, infra → sector1)
// out[2]: ring=1, sector=2 (in-progress→ ring1, finance → sector2)
// out[3]: ring=4, sector=0 (deferred → ring4, core → sector0)
// Integrar en main.cpp del skill_tree:
for (auto& o : out) {
if (o.ring < 0) continue; // nodo descartado
// ImGui::SetCursorScreenPos({base.x + o.x, base.y + o.y});
// draw_node(o.id);
}
```
## Cuando usarla
Cuando necesitas colocar N entidades en un mapa polar de progreso donde el anillo codifica el estado de avance (completado → centro, pendiente → exterior) y el sector codifica el dominio o categoria. Casos canonicos: skill_tree de issues/capabilities, roadmap visual de un proyecto, mapa de cobertura del registry por dominio.
La funcion calcula posiciones una vez; la animacion (interpolacion entre dos snapshots) la hace el caller en main.cpp, no esta funcion.
## Gotchas
- **Hash deterministico dentro de la build**: el sub-jitter angular usa FNV-1a 32-bit sobre el id (no `std::hash<string>`), lo que garantiza estabilidad cross-platform dentro del mismo proceso. El output es reproducible entre compiladores distintos.
- **Domain fuera de DomainOrder**: siempre cae en sector `n_sectors-1`. Si el caller quiere que todos los domains inesperados esten dispersos en lugar de apilados en el ultimo sector, debe pasar un `DomainOrder` exhaustivo o usar un sector dedicado `"otros"`.
- **Ring 0 con `ring_radii[0]==0`**: la funcion usa `r_inner=30.0f` automaticamente para no colocar nodos en el origen. Si el caller quiere usar el origen, debe pasar `ring_radii[0] > 0`.
- **Bins muy densamente cargados**: cuando el bin tiene mas nodos que la capacidad radial (`band_height / 18px`), se activa jitter angular `±0.4 * half_sector`. Los nodos siguen dentro del sector pero el angulo no es exactamente el centro del sector. Para bins con N > ~20 nodos el solapamiento visual es inevitable sin escalado del canvas.
- **`status_map` vacio**: se usa el mapa canonico con 5 rings. Si el caller usa status propios, DEBE pasar su propio `StatusRingMap`; de lo contrario todos cairan en ring=-1 (descartados).
## Capability growth log
(sin bumps aun — v1.0.0)
+97 -24
View File
@@ -5,6 +5,8 @@
// v1.4.0: ChipRule / ColorStop / CategoricalChip / ColorScale renderers.
#pragma once
#include "compute_column_stats.h"
#include <string>
#include <utility>
#include <vector>
@@ -49,13 +51,68 @@ struct Filter {
std::string value;
};
// ColorStop: one stop in an N-color gradient. Used by ColorRule (NumericRange)
// y por ColumnSpec (ColorScale renderer). Definido aqui (no abajo) para que
// ColorRule pueda contenerlo sin forward decl.
struct ColorStop {
float position; // 0.0 (leftmost/min) to 1.0 (rightmost/max)
std::string color; // "#rrggbb" hex color at this stop
};
// ----------------------------------------------------------------------------
// Drill extendido (fase 10). Ver issue 0079.
// Definidos aqui (no al final) para que State pueda contener vector<DrillStep>.
// ----------------------------------------------------------------------------
enum class DateGranularity { None, Year, Month, Week, Day, Hour };
enum class FilterPreset { Last7d, Last30d, Last90d, ExcludeNulls, NonZero };
// Step de drill grabado para history undo/redo (fase 10).
struct DrillStep {
int target_stage = -1; // stage donde se anadio el filter
int filter_pos = -1; // index en target_stage.filters
int prev_active_stage = 0; // active_stage antes del drill
Filter added; // filter para redo
};
// ----------------------------------------------------------------------------
// ColorRule kind (v1.5.0): pintado condicional con tres modos.
// CellBg — bg de celda cuando valor == `equals` (back-compat).
// CategoricalDot — dot a la izquierda del texto, colores autoasignados por
// valor distinto (palette deterministica). Util para
// categoricas con muchos valores donde no quieres definir
// cada color a mano.
// NumericRange — gradiente continuo de N colores (range_stops) sobre el
// bg de la celda. Para columnas numericas.
// ----------------------------------------------------------------------------
enum class ColorRuleKind : uint8_t {
CellBg = 0, // legacy (equals + color)
CategoricalDot = 1, // dots autoasignados por valor distinto
NumericRange = 2, // gradiente N-color sobre rango numerico
};
// ColorRule: pintado condicional de celdas (UI helper).
// ----------------------------------------------------------------------------
// v1.5.0: anade `kind` + campos para CategoricalDot / NumericRange.
struct ColorRule {
int col;
std::string equals;
unsigned int color;
int col;
std::string equals; // CellBg: match value; otros: ignorado
unsigned int color; // CellBg: bg color; otros: ignorado
// v1.5.0 fields (defaults preservan back-compat — kind=CellBg).
ColorRuleKind kind = ColorRuleKind::CellBg;
// CategoricalDot: alpha del relleno del dot (0..1). Tamaño en px (default 6).
float dot_alpha = 1.0f;
float dot_radius_px = 4.0f;
// Si vacio -> autoasigna desde palette interna. Si no, mapping fijo:
// pares (valor, "#rrggbb") consultados en orden.
std::vector<std::pair<std::string, std::string>> dot_map;
// NumericRange: gradiente continuo. range_min..range_max + N>=2 stops.
double range_min = 0.0;
double range_max = 1.0;
float range_alpha = 0.25f; // [0..1]; bg tint opacity
std::vector<ColorStop> range_stops; // declarado mas abajo; vacio -> default green→amber→red
};
// ----------------------------------------------------------------------------
@@ -189,11 +246,7 @@ struct ChipRule {
std::string color; // "#rrggbb" hex color for the filled circle
};
// ColorStop: one stop in an N-color gradient for ColorScale renderer (v1.4.0).
struct ColorStop {
float position; // 0.0 (leftmost/min) to 1.0 (rightmost/max)
std::string color; // "#rrggbb" hex color at this stop
};
// (ColorStop defined earlier, near Filter, so ColorRule can reference it.)
// ColumnSpec: rendering spec for one column. Indexed by column position.
struct ColumnSpec {
@@ -331,6 +384,41 @@ struct State {
bool chrome_user_set = true;
bool chrome_user_visible = false;
// ------------------------------------------------------------------------
// v1.5.0 — per-table UI state. Antes vivia en UiState singleton, lo cual
// hacia que toggle "Show stats" / seleccion de celdas / drill-history /
// row inspector se aplicasen a TODAS las tablas a la vez. Ahora cada
// State los lleva. Modal/popup state (ask AI, edit chips, etc.) sigue en
// UiState porque solo uno esta abierto en toda la app a la vez.
// ------------------------------------------------------------------------
// Stats panel (cabecera de col muestra min/max/percentiles/hist).
bool stats_mode = false;
std::vector<ColStats> stats_cache;
// Invalidacion del cache (data-identity y filter-set).
const char* const* stats_last_cells = nullptr;
int stats_last_rows = -1;
int stats_last_eff_cols = -1;
size_t stats_last_filter_h = (size_t)-1;
int stats_last_visible = -1;
// Cell selection (drag-select rectangular Ctrl+C-able).
int sel_anchor_row = -1;
int sel_anchor_col = -1;
int sel_end_row = -1;
int sel_end_col = -1;
bool sel_active = false;
bool sel_dragging = false;
// Row inspector modal target (-1 = closed).
int inspect_row = -1;
bool inspect_open = false;
// Drill history (fase 10) — per-table porque cada tabla tiene su propio
// pipeline de filters.
std::vector<DrillStep> drill_back;
std::vector<DrillStep> drill_forward;
// Helpers (definidos en compute_stage.cpp).
Stage& raw();
const Stage& raw() const;
@@ -339,19 +427,4 @@ struct State {
void ensure_stage0();
};
// ----------------------------------------------------------------------------
// Drill extendido (fase 10). Ver issue 0079.
// ----------------------------------------------------------------------------
enum class DateGranularity { None, Year, Month, Week, Day, Hour };
enum class FilterPreset { Last7d, Last30d, Last90d, ExcludeNulls, NonZero };
// Step de drill grabado para history undo/redo (fase 10).
struct DrillStep {
int target_stage = -1; // stage donde se anadio el filter
int filter_pos = -1; // index en target_stage.filters
int prev_active_stage = 0; // active_stage antes del drill
Filter added; // filter para redo
};
} // namespace data_table
+251
View File
@@ -0,0 +1,251 @@
#include "core/parse_md_frontmatter.h"
#include <algorithm>
#include <sstream>
namespace fn_md {
namespace {
// ---------------------------------------------------------------------------
// String helpers
// ---------------------------------------------------------------------------
static std::string trim(const std::string& s) {
const auto b = s.find_first_not_of(" \t\r");
if (b == std::string::npos) return {};
const auto e = s.find_last_not_of(" \t\r");
return s.substr(b, e - b + 1);
}
// Strip a trailing ` # comment` from a value string.
// Only strips if the `#` is preceded by at least one space and is outside
// any quote context. We take a simple approach: after stripping outer quotes
// we look for ` #` outside of them.
static std::string strip_comment(const std::string& s) {
// If the whole string is quoted, skip comment stripping; the comment would
// be inside the quotes and should be preserved as literal text.
if (s.size() >= 2 &&
((s.front() == '"' && s.back() == '"') ||
(s.front() == '\'' && s.back() == '\''))) {
return s;
}
const auto pos = s.find(" #");
if (pos == std::string::npos) return s;
return trim(s.substr(0, pos));
}
// Remove surrounding " or ' quotes from a value, if present.
static std::string unquote(const std::string& s) {
if (s.size() >= 2 &&
((s.front() == '"' && s.back() == '"') ||
(s.front() == '\'' && s.back() == '\''))) {
return s.substr(1, s.size() - 2);
}
return s;
}
// Parse an inline YAML list: [a, b, c] or []
static std::vector<std::string> parse_inline_list(const std::string& s) {
std::vector<std::string> result;
// strip brackets
const auto lb = s.find('[');
const auto rb = s.rfind(']');
if (lb == std::string::npos || rb == std::string::npos || lb >= rb)
return result;
const std::string inner = s.substr(lb + 1, rb - lb - 1);
if (trim(inner).empty()) return result;
// split on commas respecting quotes
std::string token;
bool in_quote = false;
char quote_ch = 0;
for (char c : inner) {
if (!in_quote && (c == '"' || c == '\'')) {
in_quote = true;
quote_ch = c;
token += c;
} else if (in_quote && c == quote_ch) {
in_quote = false;
token += c;
} else if (!in_quote && c == ',') {
result.push_back(unquote(trim(token)));
token.clear();
} else {
token += c;
}
}
const auto t = unquote(trim(token));
if (!t.empty()) result.push_back(t);
return result;
}
// Split a frontmatter line into (key, raw_value).
// Returns false if the line has no `:`.
static bool split_kv(const std::string& line, std::string& key, std::string& raw_val) {
const auto colon = line.find(':');
if (colon == std::string::npos) return false;
key = trim(line.substr(0, colon));
raw_val = trim(line.substr(colon + 1));
return true;
}
// ---------------------------------------------------------------------------
// Line iterator helpers
// ---------------------------------------------------------------------------
static std::vector<std::string> split_lines(const std::string& s) {
std::vector<std::string> lines;
std::istringstream ss(s);
std::string l;
while (std::getline(ss, l)) lines.push_back(l);
return lines;
}
// Returns true if a line looks like a block-list item: optional leading
// whitespace, then `- `.
static bool is_list_item(const std::string& line, std::string& item_val) {
const auto b = line.find_first_not_of(" \t");
if (b == std::string::npos) return false;
if (line[b] != '-') return false;
// Must have space (or be end) after `-`
if (b + 1 < line.size() && line[b + 1] != ' ') return false;
item_val = trim(line.substr(b + 1));
item_val = unquote(item_val);
return true;
}
} // anonymous namespace
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
Frontmatter parse_md_frontmatter(const std::string& content) {
Frontmatter fm;
const auto lines = split_lines(content);
if (lines.empty()) return fm;
// Check opening `---`
if (trim(lines[0]) != "---") {
fm.body = content;
return fm;
}
fm.has_frontmatter = true;
// Find closing `---`
std::size_t close_idx = std::string::npos;
for (std::size_t i = 1; i < lines.size(); ++i) {
if (trim(lines[i]) == "---") {
close_idx = i;
break;
}
}
// Determine where the body starts (after the closing `---\n`)
if (close_idx == std::string::npos) {
// No closing delimiter: treat everything after line 0 as frontmatter,
// body is empty.
close_idx = lines.size();
} else {
// Reconstruct body from lines after close_idx
std::string body;
for (std::size_t i = close_idx + 1; i < lines.size(); ++i) {
body += lines[i] + '\n';
}
fm.body = body;
}
// Parse frontmatter lines [1 .. close_idx)
std::size_t i = 1;
while (i < close_idx) {
const std::string& line = lines[i];
// Skip blank lines and top-level comment lines
const std::string tl = trim(line);
if (tl.empty() || tl[0] == '#') {
++i;
continue;
}
std::string key, raw_val;
if (!split_kv(line, key, raw_val)) {
++i;
continue;
}
if (key.empty()) { ++i; continue; }
// --- Determine the kind of value ---
// 1. Inline list: raw_val starts with `[`
if (!raw_val.empty() && raw_val[0] == '[') {
fm.fields[key] = parse_inline_list(raw_val);
++i;
continue;
}
// 2. Block scalar `|` or `>` — unsupported, store empty string
if (raw_val == "|" || raw_val == ">") {
fm.fields[key] = std::string{};
// Skip continuation lines (more indented than current)
++i;
while (i < close_idx) {
const std::string& next = lines[i];
if (next.empty()) { ++i; continue; }
if (next[0] == ' ' || next[0] == '\t') { ++i; continue; }
break;
}
continue;
}
// 3. Block list: raw_val is empty and next line(s) are list items
if (raw_val.empty()) {
// Peek ahead
std::string dummy;
std::size_t j = i + 1;
bool found_items = false;
while (j < close_idx) {
const std::string& next = lines[j];
if (trim(next).empty()) { ++j; continue; }
std::string item_val;
if (is_list_item(next, item_val)) {
found_items = true;
break;
}
break;
}
if (found_items) {
std::vector<std::string> items;
++i; // move past the key line
while (i < close_idx) {
const std::string& next = lines[i];
if (trim(next).empty()) { ++i; continue; }
std::string item_val;
if (is_list_item(next, item_val)) {
items.push_back(unquote(item_val));
++i;
} else {
break; // end of block list
}
}
fm.fields[key] = std::move(items);
continue;
}
// Empty value (e.g. `key:` with no list following)
fm.fields[key] = std::string{};
++i;
continue;
}
// 4. Plain scalar (strip trailing comment, then unquote)
const std::string stripped = strip_comment(raw_val);
fm.fields[key] = unquote(trim(stripped));
++i;
}
return fm;
}
} // namespace fn_md
+36
View File
@@ -0,0 +1,36 @@
#pragma once
#include <string>
#include <unordered_map>
#include <variant>
#include <vector>
namespace fn_md {
/// A YAML value: absent, scalar string, or list of strings.
using YamlValue = std::variant<std::monostate, std::string, std::vector<std::string>>;
/// Result of parsing a Markdown file that may contain a YAML frontmatter block.
struct Frontmatter {
std::unordered_map<std::string, YamlValue> fields;
std::string body; ///< content after the closing `---` line
bool has_frontmatter = false;
};
/// Parse a Markdown file that may (or may not) begin with a YAML frontmatter
/// block delimited by `---` lines.
///
/// Supported YAML subset (flat only, no nested maps):
/// key: value -> string (bare, or "quoted", or 'quoted')
/// key: [a, b, c] -> inline list -> vector<string>
/// key: -> block list when the next indented lines are
/// - item ` - item` -> vector<string>
/// key: | -> unsupported block scalar; stored as ""
/// # comment -> ignored (also inline trailing comments)
///
/// Notes:
/// - Keys and values are trimmed of leading/trailing whitespace.
/// - A value like `description: "foo: bar"` splits on the FIRST colon only.
/// - Pure function: no I/O, no logging, no side-effects.
Frontmatter parse_md_frontmatter(const std::string& content);
} // namespace fn_md
@@ -0,0 +1,82 @@
---
name: parse_md_frontmatter
kind: function
lang: cpp
domain: core
version: "1.0.0"
purity: pure
signature: "Frontmatter parse_md_frontmatter(const std::string& content)"
description: "Parsea frontmatter YAML simple (subset: key:value, listas inline [a,b], listas multilinea con - item) de un .md y devuelve struct con fields map + body. Pure, sin dependencias externas."
tags: [markdown, frontmatter, yaml, parser, issues, meta, registry]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["core/parse_md_frontmatter.h"]
example: |
#include "core/parse_md_frontmatter.h"
#include <fstream>
#include <sstream>
std::ifstream f("/home/lucas/fn_registry/dev/issues/0109-skill-tree-app-roadmap.md");
std::stringstream ss; ss << f.rdbuf();
auto fm = fn_md::parse_md_frontmatter(ss.str());
auto title = std::get<std::string>(fm.fields["title"]);
auto domain = std::get<std::vector<std::string>>(fm.fields["domain"]);
tested: true
tests:
- "parses_no_frontmatter"
- "parses_simple_key_value"
- "parses_quoted_strings"
- "parses_inline_list"
- "parses_multiline_list"
- "parses_body_after_frontmatter"
- "parses_empty_inline_list"
- "parses_strips_trailing_comment"
- "parses_real_issue_0109"
- "parses_real_issues_golden"
test_file_path: "cpp/tests/test_parse_md_frontmatter.cpp"
file_path: "cpp/functions/core/parse_md_frontmatter.cpp"
params:
- name: content
desc: "Contenido completo del archivo .md como string. Puede o no comenzar con bloque frontmatter `---...---`."
output: "Struct Frontmatter con: fields (unordered_map<string, YamlValue>) donde YamlValue = monostate|string|vector<string>; body (string con contenido despues del segundo ---); has_frontmatter (bool)."
notes: |
Subset YAML deliberado — los frontmatters de issues y flows del registry son planos. Si en el futuro hace falta soportar mapas anidados, considerar embeber yaml-cpp en cpp/vendor/ antes que extender este parser.
---
## Cuando usarla
Cuando una app C++ necesita leer metadata de archivos Markdown del registry (issues, flows, app.md, analysis.md, type.md). No requiere libreria externa ni yaml-cpp.
## Gotchas
- Subset YAML: NO soporta mapas anidados, NO soporta block scalars `|` ni `>` (devuelve string vacio en esos casos).
- Si un value contiene `: ` literal debe ir entre comillas: `description: "foo: bar"`.
- Comentarios solo se eliminan al final de linea en valores no citados. Un `# comentario` dentro de comillas se preserva como literal.
- Block list: las lineas de items deben estar indentadas con al menos un espacio antes de `- `. Un `- item` sin indentacion al nivel raiz se ignora (no es list item de la clave anterior).
- `YamlValue` es `std::variant` — usar `std::get<std::string>` o `std::get<std::vector<std::string>>` segun el campo. `std::get_if` es mas seguro si el tipo no es conocido a priori.
## Ejemplo
```cpp
#include "core/parse_md_frontmatter.h"
#include <fstream>
#include <sstream>
std::ifstream f("/home/lucas/fn_registry/dev/issues/0109-skill-tree-app-roadmap.md");
std::stringstream ss; ss << f.rdbuf();
auto fm = fn_md::parse_md_frontmatter(ss.str());
// Scalar string
auto id = std::get<std::string>(fm.fields["id"]); // "0109"
auto status = std::get<std::string>(fm.fields["status"]); // "in-progress"
// List
auto domain = std::get<std::vector<std::string>>(fm.fields["domain"]);
// domain[0] == "meta", domain[1] == "cpp-stack"
// Body (content after closing ---)
// fm.body starts with "\n# 0109 — skill_tree app..."
```