chore: auto-commit (286 archivos)

- .claude/agents/fn-orquestador/SKILL.md
- .claude/commands/fn_claude.md
- .claude/rules/INDEX.md
- .claude/rules/cpp_apps.md
- .claude/rules/ids_naming.md
- CHANGELOG.md
- apps/dag_engine/README.md
- apps/dag_engine/api.go
- apps/dag_engine/dags_migrated/example.yaml
- apps/dag_engine/dags_migrated/example_lineage_tracking.yaml
- ...

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 16:33:22 +02:00
parent 0b9af8f1bb
commit a03675113a
281 changed files with 12596 additions and 19526 deletions
+120
View File
@@ -0,0 +1,120 @@
// cdp_pick_element_js - JS injected via CDP Runtime.evaluate.
// Activates click-to-pick mode: hover highlights, click captures element selectors
// and prints via console.log so caller (navegator_dashboard) reads via
// Runtime.consoleAPICalled event.
//
// Inject as expression: see cdp_pick_element_js.md for the wrapping payload.
// Idempotent: re-injecting cleans up previous overlays before reattaching.
(function () {
if (window.__fn_pick_active) {
window.__fn_pick_cleanup && window.__fn_pick_cleanup();
}
window.__fn_pick_active = true;
const overlay = document.createElement('div');
overlay.id = '__fn_pick_overlay';
Object.assign(overlay.style, {
position: 'fixed', pointerEvents: 'none', zIndex: '2147483647',
border: '2px solid #f44', background: 'rgba(255,80,80,0.10)',
boxSizing: 'border-box', transition: 'all 30ms linear',
left: '0px', top: '0px', width: '0px', height: '0px',
});
document.body.appendChild(overlay);
function cssPath(el) {
if (!(el instanceof Element)) return '';
const path = [];
while (el && el.nodeType === Node.ELEMENT_NODE && el !== document.body) {
let seg = el.tagName.toLowerCase();
if (el.id) { seg += '#' + CSS.escape(el.id); path.unshift(seg); break; }
// Prefer class-based path when class is short and not utility-noise.
if (el.className && typeof el.className === 'string') {
const cls = el.className.trim().split(/\s+/).filter(c => c && c.length < 24 && !/^(active|hover|focus|selected|open|disabled|[0-9])/.test(c));
if (cls.length) seg += '.' + cls.map(CSS.escape).join('.');
}
// Disambiguate by nth-of-type when siblings share tag.
if (el.parentNode) {
const siblings = [...el.parentNode.children].filter(c => c.tagName === el.tagName);
if (siblings.length > 1) {
const idx = siblings.indexOf(el) + 1;
seg += `:nth-of-type(${idx})`;
}
}
path.unshift(seg);
el = el.parentNode;
if (path.length > 6) break;
}
return path.join(' > ');
}
function xpath(el) {
if (!el) return '';
if (el.id) return `//*[@id="${el.id}"]`;
const parts = [];
while (el && el.nodeType === Node.ELEMENT_NODE) {
let ix = 0;
for (const sib of el.parentNode ? el.parentNode.childNodes : []) {
if (sib === el) { parts.unshift(`${el.tagName.toLowerCase()}[${ix + 1}]`); break; }
if (sib.nodeType === 1 && sib.tagName === el.tagName) ix++;
}
el = el.parentNode;
if (el === document.body) { parts.unshift('body'); break; }
}
return '/html/body/' + parts.join('/');
}
function describe(el) {
if (!el) return null;
const r = el.getBoundingClientRect();
const attrs = {};
for (const a of el.attributes || []) attrs[a.name] = a.value;
return {
selector: cssPath(el),
xpath: xpath(el),
tag: el.tagName.toLowerCase(),
text: (el.innerText || el.textContent || '').trim().slice(0, 200),
attrs,
rect: { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) },
};
}
function onMove(e) {
const el = e.target;
if (!el || el === overlay) return;
const r = el.getBoundingClientRect();
overlay.style.left = r.left + 'px';
overlay.style.top = r.top + 'px';
overlay.style.width = r.width + 'px';
overlay.style.height = r.height + 'px';
}
function onClick(e) {
e.preventDefault();
e.stopPropagation();
const info = describe(e.target);
console.log('__fn_picked__', JSON.stringify(info));
cleanup();
}
function onKey(e) {
if (e.key === 'Escape') {
console.log('__fn_picked__', JSON.stringify({ cancelled: true }));
cleanup();
}
}
function cleanup() {
window.__fn_pick_active = false;
document.removeEventListener('mousemove', onMove, true);
document.removeEventListener('click', onClick, true);
document.removeEventListener('keydown', onKey, true);
overlay.remove();
}
window.__fn_pick_cleanup = cleanup;
document.addEventListener('mousemove', onMove, true);
document.addEventListener('click', onClick, true);
document.addEventListener('keydown', onKey, true);
return 'pick mode active';
})();
+45
View File
@@ -0,0 +1,45 @@
---
name: cdp_pick_element_js
kind: function
lang: js
domain: browser
version: "1.0.0"
purity: pure
signature: "(self-executing IIFE injected via CDP Runtime.evaluate)"
description: "Snippet JS inyectable que activa modo click-to-pick en una pagina Chrome remota: hover muestra overlay rojo, click captura CSS selector + XPath + tag + texto + bbox y los emite por console.log con prefijo '__fn_picked__'. El caller (navegator_dashboard) lee via CDP Runtime.consoleAPICalled."
tags: [navegator, cdp, browser, picker, scraping, js]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
file_path: "functions/browser/cdp_pick_element_js.js"
params:
- name: (none — IIFE, sin argumentos)
desc: "Snippet stand-alone. Se inyecta el fichero entero como `expression` de Runtime.evaluate."
output: "Mensaje en console.log con prefijo `__fn_picked__` seguido de JSON {selector,xpath,tag,text,attrs,rect}. Si user pulsa Escape: {cancelled: true}."
example: |
# En navegator_dashboard, al pulsar boton "Pick":
# 1. Lee el archivo cdp_pick_element_js.js (string).
# 2. Envia via WebSocket CDP al tab activo:
# {"id": N, "method": "Runtime.evaluate", "params": {"expression": "<contenido del .js>"}}
# 3. Escucha eventos "Runtime.consoleAPICalled" filtrando args[0].value == "__fn_picked__".
# 4. Parsea args[1].value como JSON -> dict con selector/xpath/tag/text/attrs/rect.
---
## Cuando usarla
Cuando un user pulsa "Pick element" en un tab de Chrome remoto para capturar un selector robusto sin abrir DevTools. Salida usable en recipes YAML o tests.
## Gotchas
- Idempotente: re-inyectar limpia overlay anterior.
- Path CSS truncado a 6 niveles para evitar selectores fragiles cross-rerender.
- Filtra clases dinamicas comunes (`active`, `hover`, `selected`...) que rotan.
- No funciona sobre iframes anidados — solo top frame.
- Escape cancela y emite `{cancelled: true}`.
- El listener captura events en fase capture para preceder a handlers de la pagina.
## Notas
Reutilizable en otras apps C++/Python que hablen CDP. La salida via `console.log` es preferible a `Runtime.evaluate` con `returnByValue=true` porque el pick es asincrono (espera click del user) y no encaja en una sola RPC.
+79
View File
@@ -0,0 +1,79 @@
package core
import (
"fmt"
"strconv"
"strings"
)
// CronExplain returns a short human-readable description of a cron expression.
// Supports 5-field standard cron and @hourly/@daily/@weekly/@monthly shortcuts.
// Returns the raw expression if the pattern is not recognized.
func CronExplain(expr string) string {
expr = strings.TrimSpace(expr)
// Handle shortcuts
switch expr {
case "@hourly":
return "hourly"
case "@daily", "@midnight":
return "daily"
case "@weekly":
return "weekly"
case "@monthly":
return "monthly"
case "@yearly", "@annually":
return "yearly"
}
fields := strings.Fields(expr)
if len(fields) < 5 {
return expr
}
// Use first 5 fields (ignore optional 6th seconds/year field)
minute := fields[0]
hour := fields[1]
dom := fields[2] // day of month
month := fields[3]
dow := fields[4] // day of week
// "every N minutes": */N in minute field, rest are *
if strings.HasPrefix(minute, "*/") && dom == "*" && month == "*" && dow == "*" {
n := minute[2:]
if n == "1" {
return "every minute"
}
return fmt.Sprintf("every %s minutes", n)
}
// "every N hours": plain minute "0", */N in hour, rest are *
if strings.HasPrefix(hour, "*/") && minute == "0" && dom == "*" && month == "*" && dow == "*" {
n := hour[2:]
if n == "1" {
return "every hour"
}
return fmt.Sprintf("every %s hours", n)
}
// "daily at HH:MM": plain numbers for minute and hour, rest are *
if dom == "*" && month == "*" && dow == "*" {
m, errM := strconv.Atoi(minute)
h, errH := strconv.Atoi(hour)
if errM == nil && errH == nil {
return fmt.Sprintf("daily at %02d:%02d", h, m)
}
}
// "weekdays at HH:MM": plain minute/hour, dow == "1-5", dom/month are *
if dom == "*" && month == "*" && dow == "1-5" {
m, errM := strconv.Atoi(minute)
h, errH := strconv.Atoi(hour)
if errM == nil && errH == nil {
return fmt.Sprintf("weekdays at %02d:%02d", h, m)
}
}
// Not recognized — return raw expression
return expr
}
+61
View File
@@ -0,0 +1,61 @@
---
name: cron_explain
kind: function
lang: go
domain: core
version: "1.0.0"
purity: pure
signature: "func CronExplain(expr string) string"
description: "Convierte una expresion cron (5 campos o shortcut @daily/@hourly/etc.) en una frase humana corta. Reconoce patrones comunes: every N minutes/hours, daily/weekdays at HH:MM, y shortcuts. Devuelve el expr crudo si no encaja en ningun patron. Sin dependencias externas, solo stdlib."
tags: ["cron", "scheduler", "humanize"]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["fmt", "strconv", "strings"]
tested: true
tests:
- "every 15 minutes"
- "daily at 00:00"
- "weekdays at 09:00"
- "every 30 minutes"
- "every 2 hours"
- "shortcut @hourly"
- "shortcut @daily"
- "shortcut @weekly"
- "shortcut @monthly"
- "unknown returns raw"
- "invalid too few fields"
- "every minute"
- "daily at 14:30"
- "every hour zero min"
- "shortcut @midnight"
- "weekdays at 08:00"
test_file_path: "functions/core/cron_explain_test.go"
file_path: "functions/core/cron_explain.go"
params:
- name: expr
desc: "Expresion cron de 5 campos (min hora dom mes dow) o shortcut (@hourly, @daily, @weekly, @monthly, @yearly). Se ignoran campos extra (6to campo segundos/year)."
output: "Frase legible en ingles: 'every N minutes', 'daily at HH:MM', 'weekdays at HH:MM', 'hourly', 'daily', 'weekly', 'monthly'. Devuelve expr sin modificar si el patron no es reconocido."
---
## Ejemplo
```go
fmt.Println(CronExplain("*/15 * * * *")) // every 15 minutes
fmt.Println(CronExplain("0 0 * * *")) // daily at 00:00
fmt.Println(CronExplain("0 9 * * 1-5")) // weekdays at 09:00
fmt.Println(CronExplain("*/30 * * * *")) // every 30 minutes
fmt.Println(CronExplain("0 */2 * * *")) // every 2 hours
fmt.Println(CronExplain("@hourly")) // hourly
fmt.Println(CronExplain("5 4 * * 0")) // 5 4 * * 0 (not recognized, returned raw)
```
## Cuando usarla
Cuando necesites mostrar al usuario una descripcion legible de un schedule cron en una UI, log o CLI. Antes de renderizar un campo `cron_expr` en un dashboard o TUI.
## Gotchas
Funcion pura — nunca falla ni entra en panico. Patrones reconocidos son los comunes; expresiones con listas (`1,3,5`), rangos en horas, o combinaciones complejas devuelven el expr crudo sin error.
+37
View File
@@ -0,0 +1,37 @@
package core
import "testing"
func TestCronExplain(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{"every 15 minutes", "*/15 * * * *", "every 15 minutes"},
{"daily at 00:00", "0 0 * * *", "daily at 00:00"},
{"weekdays at 09:00", "0 9 * * 1-5", "weekdays at 09:00"},
{"every 30 minutes", "*/30 * * * *", "every 30 minutes"},
{"every 2 hours", "0 */2 * * *", "every 2 hours"},
{"shortcut @hourly", "@hourly", "hourly"},
{"shortcut @daily", "@daily", "daily"},
{"shortcut @weekly", "@weekly", "weekly"},
{"shortcut @monthly", "@monthly", "monthly"},
{"unknown returns raw", "5 4 * * 0", "5 4 * * 0"},
{"invalid too few fields", "bad", "bad"},
{"every minute", "*/1 * * * *", "every minute"},
{"daily at 14:30", "30 14 * * *", "daily at 14:30"},
{"every hour zero min", "0 */1 * * *", "every hour"},
{"shortcut @midnight", "@midnight", "daily"},
{"weekdays at 08:00", "0 8 * * 1-5", "weekdays at 08:00"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := CronExplain(tc.input)
if got != tc.want {
t.Errorf("CronExplain(%q) = %q, want %q", tc.input, got, tc.want)
}
})
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ version: "1.0.0"
purity: pure
signature: "func CronMatch(sched CronSchedule, t time.Time) bool"
description: "Verifica si un instante de tiempo coincide con un cron schedule. Compara los 5 campos (minuto, hora, dia del mes, mes, dia de la semana) y retorna true si todos coinciden."
tags: [cron, scheduling, matching, time, pure, pendiente-usar]
tags: [cron, scheduling, matching, time, pure, pendiente-usar, scheduler]
uses_functions: []
uses_types: [cron_schedule_go_core]
returns: []
+1
View File
@@ -20,6 +20,7 @@ type DagStep struct {
Command string
Script string
Args []string
Function string `json:"function,omitempty"` // ID de funcion del registry (ej "audit_capability_groups_go_infra"). Si set, Command/Script se ignoran; el executor construye "fn run <Function> <Args...>".
Shell string
Dir string
Depends []string
+2
View File
@@ -15,6 +15,7 @@ type rawDagStep struct {
Command string `yaml:"command"`
Script string `yaml:"script"`
Args []string `yaml:"args"`
Function string `yaml:"function"`
Shell string `yaml:"shell"`
Dir string `yaml:"dir"`
WorkingDir string `yaml:"working_dir"`
@@ -196,6 +197,7 @@ func normalizeStep(rs rawDagStep) (DagStep, error) {
Command: rs.Command,
Script: rs.Script,
Args: rs.Args,
Function: rs.Function,
Shell: rs.Shell,
Dir: dir,
Depends: rs.Depends,
+24
View File
@@ -187,6 +187,30 @@ steps:
}
})
t.Run("parsea step con function id y args", func(t *testing.T) {
data := []byte(`
name: dag-function
steps:
- name: foo
function: audit_capability_groups_go_infra
args:
- --json
`)
dag, err := DagParse(data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(dag.Steps) != 1 {
t.Fatalf("Steps: got %d, want 1", len(dag.Steps))
}
if dag.Steps[0].Function != "audit_capability_groups_go_infra" {
t.Errorf("Steps[0].Function: got %q, want %q", dag.Steps[0].Function, "audit_capability_groups_go_infra")
}
if len(dag.Steps[0].Args) != 1 || dag.Steps[0].Args[0] != "--json" {
t.Errorf("Steps[0].Args: got %v, want [--json]", dag.Steps[0].Args)
}
})
t.Run("parsea type graph", func(t *testing.T) {
data := []byte(`
name: dag-graph
+21 -1
View File
@@ -1,6 +1,13 @@
package core
import "fmt"
import (
"fmt"
"regexp"
)
// functionIDPattern matches registry IDs in `<name>_<lang>_<domain>` form.
// Compiled once and reused across validations.
var functionIDPattern = regexp.MustCompile(`^[a-z0-9_]+_[a-z]+_[a-z]+$`)
// DagValidate validates a DagDefinition for structural correctness.
// Checks: steps have name/ID, no duplicate names/IDs, all depends reference
@@ -31,6 +38,19 @@ func DagValidate(dag DagDefinition) DagValidationResult {
result.Warnings = append(result.Warnings,
fmt.Sprintf("step %q: has both command and script", ref))
}
// Function-step validation.
if step.Function != "" {
if !functionIDPattern.MatchString(step.Function) {
result.Errors = append(result.Errors,
fmt.Sprintf("step %s: invalid function id format: %s", ref, step.Function))
result.Valid = false
}
if step.Command != "" || step.Script != "" {
result.Warnings = append(result.Warnings,
fmt.Sprintf("step %s: function takes precedence; command/script ignored", ref))
}
}
}
if !result.Valid {
+1 -1
View File
@@ -7,7 +7,7 @@ version: "1.0.0"
purity: pure
signature: "func DagValidate(dag DagDefinition) DagValidationResult"
description: "Valida un DagDefinition para correcto uso estructural. Verifica que cada step tenga nombre o ID, que no haya duplicados, que todos los depends referencien steps existentes y que no haya ciclos (algoritmo de Kahn). Si el DAG es valido, calcula los niveles topologicos."
tags: [dag, validation, workflow, graph, pure]
tags: [dag, validation, workflow, graph, pure, validator]
uses_functions: [dag_topo_sort_go_core]
uses_types: [dag_definition_go_core, dag_validation_result_go_core]
returns: []
+1 -1
View File
@@ -7,7 +7,7 @@ version: "1.0.0"
purity: pure
signature: "func NextCronTime(schedule CronSchedule, after time.Time) time.Time"
description: "Calcula la proxima ejecucion de un cron schedule despues de un tiempo dado. Avanza minuto a minuto saltando campos no coincidentes. Retorna zero time si no hay match en 366 dias (schedule imposible)."
tags: [cron, scheduling, time, next, pure]
tags: [cron, scheduling, time, next, pure, scheduler]
uses_functions: [parse_cron_expr_go_core]
uses_types: [cron_schedule_go_core]
returns: []
+1 -1
View File
@@ -7,7 +7,7 @@ version: "1.0.0"
purity: pure
signature: "func ParseCronExpr(expr string) (CronSchedule, error)"
description: "Parsea una expresion cron estandar de 5 campos en un CronSchedule con valores expandidos. Soporta *, rangos (1-5), listas (1,3,5), pasos (*/15) y aliases (@hourly, @daily, @weekly, @monthly, @yearly). No soporta segundos ni years estilo Quartz."
tags: [cron, scheduling, parsing, time, pure]
tags: [cron, scheduling, parsing, time, pure, scheduler]
uses_functions: []
uses_types: [cron_schedule_go_core]
returns: []
+1 -1
View File
@@ -7,7 +7,7 @@ version: "1.0.0"
purity: pure
signature: "func Clip(data []float64, min, max float64) []float64"
description: "Recorta cada valor del slice para que quede dentro del rango [min, max]."
tags: [datascience, clamp, clip, range, pendiente-usar]
tags: [datascience, clamp, clip, range, pendiente-usar, transformer]
uses_functions: []
uses_types: []
returns: []
+1 -1
View File
@@ -7,7 +7,7 @@ version: "1.0.0"
purity: pure
signature: "func DetectOutliers(data []float64, threshold float64) []bool"
description: "Detecta outliers en un slice de float64 usando z-score. Devuelve true para valores cuyo |z-score| supera el umbral."
tags: [datascience, statistics, outlier, anomaly, pendiente-usar]
tags: [datascience, statistics, outlier, anomaly, pendiente-usar, validator]
uses_functions: []
uses_types: []
returns: []
+1 -1
View File
@@ -7,7 +7,7 @@ version: "1.0.0"
purity: impure
signature: "func FetchDataFrame(dsn, query string) ([]map[string]any, error)"
description: "Ejecuta una consulta SQL contra un DSN y retorna los resultados como slice de mapas columna-valor."
tags: [datascience, io, bigquery, fetch, pendiente-usar]
tags: [datascience, io, bigquery, fetch, pendiente-usar, extractor]
uses_functions: []
uses_types: []
returns: []
+1 -1
View File
@@ -7,7 +7,7 @@ version: "1.0.0"
purity: pure
signature: "func GroupBy[T any, K comparable](xs []T, keyFn func(T) K) map[K][]T"
description: "Agrupa los elementos de un slice según una función clave, devolviendo un mapa de clave a slice de elementos."
tags: [datascience, group, aggregate, generic, pendiente-usar]
tags: [datascience, group, aggregate, generic, pendiente-usar, transformer]
uses_functions: []
uses_types: []
returns: []
+1 -1
View File
@@ -7,7 +7,7 @@ version: "1.0.0"
purity: pure
signature: "func Histogram(data []float64, buckets int) []int"
description: "Calcula las frecuencias de un slice de float64 distribuidas en un número dado de buckets equiespaciados."
tags: [datascience, statistics, histogram, frequency, pendiente-usar]
tags: [datascience, statistics, histogram, frequency, pendiente-usar, transformer]
uses_functions: []
uses_types: []
returns: []
+1 -1
View File
@@ -7,7 +7,7 @@ version: "1.0.0"
purity: pure
signature: "func Impute(data []float64) []float64"
description: "Rellena valores NaN en un slice de float64 usando forward-fill, reemplazando cada NaN con el último valor válido anterior."
tags: [datascience, impute, missing, fill, pendiente-usar]
tags: [datascience, impute, missing, fill, pendiente-usar, transformer]
uses_functions: []
uses_types: []
returns: []
+1 -1
View File
@@ -7,7 +7,7 @@ version: "1.0.0"
purity: impure
signature: "func LoadCSV(path string) ([]map[string]string, error)"
description: "Carga un archivo CSV desde disco y lo retorna como slice de mapas columna-valor."
tags: [datascience, io, csv, load, pendiente-usar]
tags: [datascience, io, csv, load, pendiente-usar, extractor]
uses_functions: []
uses_types: []
returns: []
+1 -1
View File
@@ -7,7 +7,7 @@ version: "1.0.0"
purity: impure
signature: "func LoadParquet(path string) ([]map[string]any, error)"
description: "Carga un archivo Parquet desde disco y lo retorna como slice de mapas columna-valor."
tags: [datascience, io, parquet, load, pendiente-usar]
tags: [datascience, io, parquet, load, pendiente-usar, extractor]
uses_functions: []
uses_types: []
returns: []
+80
View File
@@ -0,0 +1,80 @@
package infra
import (
"os"
"path/filepath"
)
// AppLocationViolation describes an artefact found in a language-specific
// directory where it does not belong (e.g. cpp/apps/, python/analysis/).
type AppLocationViolation struct {
Path string // relative from repoRoot (e.g. "cpp/apps/foo")
Kind string // "app" | "analysis"
Lang string // "cpp" | "python" | "bash" | "frontend"
}
// AuditAppLocation scans language-specific directories that must NOT contain
// artefacts (apps or analyses) and returns every subdirectory that holds an
// app.md or analysis.md manifest.
//
// Prohibited directories checked:
// - cpp/apps/, cpp/analysis/
// - python/apps/, python/analysis/
// - bash/apps/, bash/analysis/
// - frontend/apps/, frontend/analysis/
//
// If a prohibited directory does not exist it is silently skipped.
// Artefacts under the canonical apps/ and analysis/ roots are not flagged.
func AuditAppLocation(repoRoot string) ([]AppLocationViolation, error) {
type candidate struct {
lang string
kind string
dir string // relative dir inside repoRoot
}
candidates := []candidate{
{lang: "cpp", kind: "app", dir: "cpp/apps"},
{lang: "cpp", kind: "analysis", dir: "cpp/analysis"},
{lang: "python", kind: "app", dir: "python/apps"},
{lang: "python", kind: "analysis", dir: "python/analysis"},
{lang: "bash", kind: "app", dir: "bash/apps"},
{lang: "bash", kind: "analysis", dir: "bash/analysis"},
{lang: "frontend", kind: "app", dir: "frontend/apps"},
{lang: "frontend", kind: "analysis", dir: "frontend/analysis"},
}
manifest := map[string]string{
"app": "app.md",
"analysis": "analysis.md",
}
var out []AppLocationViolation
for _, c := range candidates {
absDir := filepath.Join(repoRoot, c.dir)
entries, err := os.ReadDir(absDir)
if err != nil {
if os.IsNotExist(err) {
continue
}
return nil, err
}
mf := manifest[c.kind]
for _, e := range entries {
if !e.IsDir() {
continue
}
mdPath := filepath.Join(absDir, e.Name(), mf)
if _, err := os.Stat(mdPath); err == nil {
out = append(out, AppLocationViolation{
Path: filepath.Join(c.dir, e.Name()),
Kind: c.kind,
Lang: c.lang,
})
}
}
}
return out, nil
}
+55
View File
@@ -0,0 +1,55 @@
---
name: audit_app_location
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func AuditAppLocation(repoRoot string) ([]AppLocationViolation, error)"
description: "Detecta artefactos (apps y analyses) ubicados en carpetas de lenguaje (cpp/apps/, python/apps/, bash/apps/, frontend/apps/, y sus equivalentes /analysis/). Estas ubicaciones violan la convencion del registry (issue 0096): los artefactos deben vivir en apps/ o analysis/ en la raiz, o en projects/*/apps/ y projects/*/analysis/. Retorna una lista de AppLocationViolation con la ruta relativa, kind (app|analysis) y lang (cpp|python|bash|frontend)."
tags: [doctor, audit, location, artefacts, registry]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["os", "path/filepath"]
params:
- name: repoRoot
desc: "Ruta absoluta a la raiz del repositorio fn_registry. Todas las rutas en las violaciones son relativas a este directorio."
output: "Lista de AppLocationViolation con (Path, Kind, Lang) por cada artefacto mal ubicado. Vacia si no hay violaciones. Error si un directorio prohibido existe pero no es legible."
tested: true
tests:
- "detecta app.md en directorio prohibido cpp/apps"
- "directorios prohibidos inexistentes no producen error"
- "detecta analysis.md en directorio prohibido python/analysis"
test_file_path: "functions/infra/audit_app_location_test.go"
file_path: "functions/infra/audit_app_location.go"
---
## Ejemplo
```go
violations, err := AuditAppLocation("/home/lucas/fn_registry")
if err != nil {
log.Fatal(err)
}
for _, v := range violations {
fmt.Printf("[%s/%s] %s → mover a apps/%s o projects/.../apps/%s\n",
v.Lang, v.Kind, v.Path,
filepath.Base(v.Path), filepath.Base(v.Path))
}
// Ejemplo de salida:
// [cpp/app] cpp/apps/my_tool → mover a apps/my_tool o projects/.../apps/my_tool
```
## Cuando usarla
Usar como check de `fn doctor artefacts` o en pre-commit para detectar artefactos que se crearon en la carpeta del lenguaje equivocada. Invocar despues de `fn index` para validar el estado del repo.
## Gotchas
- Solo revisa subdirectorios de nivel 1 dentro de cada directorio prohibido. No escanea recursivamente.
- Un subdirectorio sin `app.md` ni `analysis.md` NO se reporta (ej. `cpp/apps/bar/` sin manifest = ignorado).
- Los artefactos en `apps/`, `analysis/`, `projects/*/apps/` y `projects/*/analysis/` son ubicaciones canonicas y NO se comprueban.
- `AppLocationViolation` esta definida en el mismo paquete (`infra`), no como tipo separado del registry.
@@ -0,0 +1,92 @@
package infra
import (
"os"
"path/filepath"
"testing"
)
func TestAuditAppLocation(t *testing.T) {
t.Run("detecta app.md en directorio prohibido cpp/apps", func(t *testing.T) {
root := t.TempDir()
// Violacion: cpp/apps/foo tiene app.md
fooDir := filepath.Join(root, "cpp", "apps", "foo")
if err := os.MkdirAll(fooDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(fooDir, "app.md"), []byte("name: foo\n"), 0o644); err != nil {
t.Fatal(err)
}
// Ignorado: cpp/apps/bar sin app.md
barDir := filepath.Join(root, "cpp", "apps", "bar")
if err := os.MkdirAll(barDir, 0o755); err != nil {
t.Fatal(err)
}
// Ignorado: apps/baz tiene app.md pero es el lugar canonico
bazDir := filepath.Join(root, "apps", "baz")
if err := os.MkdirAll(bazDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(bazDir, "app.md"), []byte("name: baz\n"), 0o644); err != nil {
t.Fatal(err)
}
violations, err := AuditAppLocation(root)
if err != nil {
t.Fatalf("AuditAppLocation returned error: %v", err)
}
if len(violations) != 1 {
t.Fatalf("expected 1 violation, got %d: %+v", len(violations), violations)
}
v := violations[0]
wantPath := filepath.Join("cpp", "apps", "foo")
if v.Path != wantPath {
t.Errorf("Path: got %q, want %q", v.Path, wantPath)
}
if v.Kind != "app" {
t.Errorf("Kind: got %q, want %q", v.Kind, "app")
}
if v.Lang != "cpp" {
t.Errorf("Lang: got %q, want %q", v.Lang, "cpp")
}
})
t.Run("directorios prohibidos inexistentes no producen error", func(t *testing.T) {
root := t.TempDir()
violations, err := AuditAppLocation(root)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(violations) != 0 {
t.Errorf("expected 0 violations, got %d", len(violations))
}
})
t.Run("detecta analysis.md en directorio prohibido python/analysis", func(t *testing.T) {
root := t.TempDir()
anaDir := filepath.Join(root, "python", "analysis", "my_analysis")
if err := os.MkdirAll(anaDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(anaDir, "analysis.md"), []byte("name: my_analysis\n"), 0o644); err != nil {
t.Fatal(err)
}
violations, err := AuditAppLocation(root)
if err != nil {
t.Fatalf("AuditAppLocation returned error: %v", err)
}
if len(violations) != 1 {
t.Fatalf("expected 1 violation, got %d: %+v", len(violations), violations)
}
v := violations[0]
if v.Kind != "analysis" || v.Lang != "python" {
t.Errorf("got Kind=%q Lang=%q, want Kind=analysis Lang=python", v.Kind, v.Lang)
}
})
}
+1 -1
View File
@@ -7,7 +7,7 @@ version: "1.0.0"
purity: pure
signature: "func ConfigValidate(cfg any) ConfigValidation"
description: "Valida una struct de configuracion usando struct tags. Acumula todos los errores (required, format:email, format:url, min, max, oneof) sin detener en el primero. Retorna ConfigValidation con IsValid=true si no hay errores."
tags: [config, validation, env, infra, reflect, pendiente-usar]
tags: [config, validation, env, infra, reflect, pendiente-usar, validator]
uses_functions: []
uses_types: [config_validation_go_infra, config_error_go_infra]
returns: [config_validation_go_infra]
+1 -1
View File
@@ -7,7 +7,7 @@ version: "1.0.0"
purity: impure
signature: "func DBInsertRow(db *sql.DB, table string, row map[string]any) (int64, error)"
description: "Genera y ejecuta un INSERT de una sola fila desde un map columna→valor. Retorna el last insert ID. Sanitiza nombres de tabla y columnas."
tags: [database, sql, insert, row, dynamic]
tags: [database, sql, insert, row, dynamic, sink]
uses_functions: []
uses_types: []
returns: []
+1 -1
View File
@@ -7,7 +7,7 @@ version: "1.0.0"
purity: pure
signature: "func FileValidateType(header []byte, allowedTypes []string) (string, bool)"
description: "Detecta el MIME type real de un archivo a partir de sus primeros bytes (magic bytes) y verifica que esta en la lista de tipos permitidos. Mas seguro que confiar en el header Content-Type del request."
tags: [file, validate, mime, magic, security, upload, infra]
tags: [file, validate, mime, magic, security, upload, infra, validator]
uses_functions: []
uses_types: []
returns: []
+1 -1
View File
@@ -7,7 +7,7 @@ version: "1.0.0"
purity: impure
signature: "func HttpDownloadFile(url, destPath string, headers map[string]string, timeout time.Duration) (int64, error)"
description: "Descarga url en destPath en streaming con io.Copy. Crea directorios con os.MkdirAll. Usa archivo temporal + rename para atomicidad (no deja archivo corrupto si falla). Retorna bytes escritos."
tags: [http, download, file, streaming, atomic, network, stdlib, infra, pendiente-usar]
tags: [http, download, file, streaming, atomic, network, stdlib, infra, pendiente-usar, extractor]
uses_functions: []
uses_types: []
returns: []
+1 -1
View File
@@ -7,7 +7,7 @@ version: "1.0.0"
purity: impure
signature: "func HttpGetJSON(url string, headers map[string]string, timeout time.Duration) (map[string]any, error)"
description: "GET request que espera JSON. Agrega Accept: application/json automaticamente. Retorna error con status code si >= 400. Siempre cierra body con defer."
tags: [http, json, get, client, network, stdlib, infra, pendiente-usar]
tags: [http, json, get, client, network, stdlib, infra, pendiente-usar, extractor]
uses_functions: []
uses_types: []
returns: []
+1 -1
View File
@@ -7,7 +7,7 @@ version: "1.0.0"
purity: impure
signature: "func HttpPostJSON(url string, body any, headers map[string]string, timeout time.Duration) (map[string]any, error)"
description: "POST request con body JSON serializado con json.Marshal. Agrega Content-Type: application/json y Accept: application/json. Retorna error con status code si >= 400."
tags: [http, json, post, client, network, stdlib, infra, pendiente-usar]
tags: [http, json, post, client, network, stdlib, infra, pendiente-usar, sink]
uses_functions: []
uses_types: []
returns: []