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:
@@ -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';
|
||||
})();
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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: []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: []
|
||||
|
||||
@@ -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: []
|
||||
|
||||
@@ -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: []
|
||||
|
||||
@@ -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: []
|
||||
|
||||
@@ -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: []
|
||||
|
||||
@@ -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: []
|
||||
|
||||
@@ -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: []
|
||||
|
||||
@@ -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: []
|
||||
|
||||
@@ -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: []
|
||||
|
||||
@@ -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: []
|
||||
|
||||
@@ -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: []
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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: []
|
||||
|
||||
@@ -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: []
|
||||
|
||||
@@ -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: []
|
||||
|
||||
@@ -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: []
|
||||
|
||||
@@ -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: []
|
||||
|
||||
Reference in New Issue
Block a user