Lectura
Carga archivos YAML desde una carpeta o archivo individual con fileWalker (glob) y loadYaml (paquete yaml). Soporta árboles anidados rules/mvp/<area>/*.yaml.
Módulo TypeScript que toma reglas FARO en YAML, las valida, las normaliza a JSON y las carga en faro.rule_definitions. Sin parser, los YAMLs son documentación. Con parser, las tensiones son configuración auditable, versionable, testeable.
FARO-ENG-002 es el módulo técnico TypeScript / Node.js que toma archivos YAML escritos contra el DSL de reglas-mvp-yaml.html (FARO-CFG-001), los valida en ocho dimensiones, los normaliza a JSON interno y los carga en faro.rule_definitions para que el motor evaluador pueda usarlos.
Regla de negocio dura. Ninguna regla YAML entra al sistema si no puede ser validada, auditada y testeada. Si el parser deja pasar basura, el motor después ejecuta basura. Y ahí no hay IA que salve el incendio.
El flujo conceptual del parser, en una sola línea: YAML → lectura → parsing → validación estructura → validación operadores → validación KPIs → validación acciones → validación roles → validación evidencia → validación tests → validación seguridad → conversión JSON normalizado → persistencia faro.rule_definitions → reporte.
rules/mvp/**/*.yaml
↓ loadYaml()
unknown raw object
↓ validateSchema() (Ajv + rule.schema.json)
schema-valid FaroRule
↓ validateOperators() (operatorRegistry)
↓ validateKpis() (kpiRegistry / FARO-SQL-007 pendiente)
↓ validateActions() (actionRegistry)
↓ validateRoles() (roleRegistry)
↓ validateEvidence() (evidenceRegistry)
↓ validateTests() (positive + negative)
↓ validateSecurity() (forbidden keys)
ValidationResult (issues[])
↓ if errors > 0 → ABORT
↓ normalizeRule()
NormalizedRule (ruleBody jsonb)
↓ upsertRuleDefinition() → faro.rule_definitions
El parser no evalúa la regla contra datos reales. Eso corresponde a FARO-ENG-003 · Motor Evaluador MVP. Tampoco crea tensiones, acciones ni alimenta Score. Su único trabajo: convertir YAML editable en JSON gobernable y dejarlo persistido con auditoría.
El contexto demo para todos los ejemplos de este documento es Empresa Demo Cuyo S.A. (company_id = 10000000-0000-0000-0000-000000000001). Las reglas se cargan en modo global (company_id = NULL) o por empresa específica (--company-id explícito).
Las tensiones deben vivir como configuración declarativa, versionada y auditable. El parser cumple cinco funciones críticas que separan un YAML editable de un sistema operable.
Carga archivos YAML desde una carpeta o archivo individual con fileWalker (glob) y loadYaml (paquete yaml). Soporta árboles anidados rules/mvp/<area>/*.yaml.
Revisa estructura, campos obligatorios, tipos y operadores contra rule.schema.json con Ajv, y aplica validadores cruzados para condiciones, escalation y seguridad.
Verifica que KPIs, acciones, roles y evidencias existan en los registries oficiales. Una regla no puede apuntar a KPI-INVENTADO-999 y pasar a producción.
Convierte YAML a JSON interno consistente con NormalizedRule: extrae rule_code, version, status a columnas y serializa el resto en ruleBody jsonb.
Guarda reglas en faro.rule_definitions con ON CONFLICT (company_id, rule_code, version) DO UPDATE. Respeta RLS con set_config('app.company_id', ...).
Aclaración estricta de scope. El parser no evalúa reglas contra datos reales, no crea tensiones, no crea acciones, no alimenta Score. Esas responsabilidades pertenecen a FARO-ENG-003. El parser es un compilador y un cargador, no un runtime.
Separación dura entre FARO-ENG-002 (este parser) y FARO-ENG-003 (motor evaluador). Mezclar responsabilidades fue una de las decisiones que rompió implementaciones anteriores de motores de reglas.
rule.schema.jsonACT-* en registryEVD-* obligatorionormalizeRule()validate / import / test / listupsertRuleDefinitionValidationIssue con hintcompany_id opcionalkpi_snapshots · FARO-ENG-003Por qué la separación es dura. Un parser que también ejecuta termina siendo imposible de testear: no se puede validar la regla sin DB, no se puede simular sin snapshots, no se puede mantener sin reescribir el motor. La frontera limpia permite que el parser corra en CI (sin DB, solo schema), y que el motor corra en producción (sin tocar archivos YAML, solo lectura de rule_definitions).
Stack de dependencias mínimo, sin frameworks pesados. Todo runs en Node 22+ con tsx en desarrollo y vitest para tests de framework. Sin frameworks de IoC, sin ORM, sin bundlers en el parser.
| Paquete | Función | Por qué se eligió |
|---|---|---|
| typescript | Tipado estricto | Tipos para FaroRule, ValidationResult, autodocumentación, IDE help. strict: true obligatorio. |
| tsx | Ejecutar TS en dev | Sin paso de build en dev. CLI corre directo. Menor fricción para iterar reglas. |
| yaml | Leer YAML | Parser maduro, mantiene comentarios, soporta anchors. Más confiable que js-yaml legacy. |
| ajv | Validar JSON Schema | Compilador JIT, error reporting detallado, draft 2020-12 soportado. Estándar de facto. |
| ajv-formats | Formatos adicionales | UUID, email, date-time, uri. Necesario para metadata que use formatos estándar. |
| zod | Validación runtime opcional | Tipos en runtime para inputs de tests YAML (record dinámico). Complemento de Ajv para casos no-schema. |
| pg | PostgreSQL driver | Cliente oficial. Soporta jsonb, transacciones, set_config para RLS. |
| commander | CLI | Subcomandos limpios, parseo de flags, help automático. Lo usa el resto del ecosistema FARO. |
| glob | Buscar archivos | Patrones **/*.yaml para recorrer árbol de reglas. v11 con API promise nativa. |
| dotenv | Variables de entorno | Carga .env en dev. Producción usa env del proceso (Docker, k8s, Vercel). |
| vitest | Tests de framework | Más rápido que Jest, ESM nativo, compatible con TypeScript sin config extra. Tests de los validadores. |
Decisión deliberada: no hay framework HTTP, no hay framework de DI, no hay ORM. El parser es un módulo + CLI, no un servicio. Si en el futuro se necesita exponer endpoints (admin UI), se agrega una capa @faro/rule-parser-api por separado.
faro-rule-parser/Layout completo del repo / módulo. Cada carpeta tiene responsabilidad única: parser/ lee, validators/ revisa, registry/ dice qué existe, db/ persiste, commands/ orquesta, testing/ ejecuta mock.
faro-rule-parser/ package.json tsconfig.json .env.example README.md src/ index.ts # export público del módulo cli.ts # entrypoint CLI (commander) config/ parser.config.ts # lectura de env, defaults schemas/ rule.schema.json # JSON Schema 2020-12 de FaroRule types/ rule.types.ts # FaroRule, Condition, Severity, etc. validation.types.ts # ValidationResult, NormalizedRule, etc. parser/ loadYaml.ts # fs.readFile + YAML.parse parseRuleFile.ts # pipeline por archivo normalizeRule.ts # FaroRule → NormalizedRule validators/ validateSchema.ts # Ajv compile + run validateOperators.ts # conditions + escalation operators validateKpis.ts # required_kpis + condition KPIs validateActions.ts # output.recommended_actions validateRoles.ts # assign_to_role + approver_role validateEvidence.ts # output.evidence_required validateTests.ts # positive + negative cases validateSecurity.ts # forbidden keys + score warnings validateRule.ts # orquestador de 8 validadores conditionWalker.ts # helper: collectConditions(group) registry/ kpiRegistry.ts # MVP local · futuro: leer faro.kpi_definitions actionRegistry.ts # MVP_ACTION_CODES = ACT-COM-001..ACT-DIR-001 roleRegistry.ts # MVP_ROLE_CODES = 8 roles base evidenceRegistry.ts # MVP_EVIDENCE_CODES = EVD-001..EVD-012 operatorRegistry.ts # SUPPORTED_OPERATORS = 13 operadores db/ db.ts # Pool pg + withDbContext (RLS aware) ruleRepository.ts # upsertRuleDefinition (ON CONFLICT) commands/ validateCommand.ts # CLI: validate --path importCommand.ts # CLI: import --path [--company-id] [--dry-run] testCommand.ts # CLI: test --path listCommand.ts # CLI: list (futuro: lee rule_definitions) testing/ ruleTestRunner.ts # runRuleTests(rule): RuleTestResult[] evaluateMockConditions.ts # evaluator local · NO motor real utils/ logger.ts # console wrapper con niveles errors.ts # clases de error tipadas fileWalker.ts # findYamlFiles(path) con glob rules/ mvp/ commercial/ # TNS-001..TNS-003 (crecimiento, descuento, vendedor) finance/ # TNS-004..TNS-005 (caja, mora) stock/ # TNS-006..TNS-008 (crítico, inmovilizado, urgente) execution/ # TNS-009..TNS-010 (acciones vencidas, sin evidencia) data_quality/ # TNS-011 (score calidad) tests/ fixtures/ valid-rule.yaml # caso happy path invalid-rule.yaml # falta rule_code, operador inválido, KPI roto
El árbol cubre el módulo de parser completo. La carpeta rules/mvp/ es el cliente del módulo: contiene los 11 YAMLs canónicos del MVP organizados por área. En producción puede vivir en el mismo repo o en uno separado consumido por CI.
@faro/rule-parserTres archivos definen la configuración del módulo: package.json (dependencias + scripts), tsconfig.json (compilación estricta) y .env.example (contrato de variables de entorno).
{
"name": "@faro/rule-parser",
"version": "1.0.0",
"description": "FARO Connect YAML DSL Rule Parser",
"type": "module",
"scripts": {
"dev": "tsx src/cli.ts",
"validate": "tsx src/cli.ts validate --path ./rules/mvp",
"import": "tsx src/cli.ts import --path ./rules/mvp",
"test:rules": "tsx src/cli.ts test --path ./rules/mvp",
"list": "tsx src/cli.ts list",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"commander": "^12.1.0",
"dotenv": "^16.4.5",
"glob": "^11.0.0",
"pg": "^8.13.1",
"yaml": "^2.6.1",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^22.10.0",
"@types/pg": "^8.11.10",
"tsx": "^4.19.2",
"typescript": "^5.7.2",
"vitest": "^2.1.8"
}
}
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"outDir": "dist",
"rootDir": ".",
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"]
}
# Conexión PostgreSQL al schema faro DATABASE_URL=postgresql://faro_app:password@localhost:5432/faro # Carpeta raíz de reglas YAML FARO_RULES_PATH=./rules/mvp # Modo de import: dry-run (no escribe DB) o write FARO_IMPORT_MODE=dry-run # Empresa target por defecto (UUID o vacío para global) FARO_DEFAULT_COMPANY_ID= # Modo strict: aborta también en warnings FARO_STRICT_MODE=true
Scripts conceptuales: npm run validate recorre la carpeta y reporta sin tocar DB; npm run import persiste reglas válidas; npm run test:rules ejecuta los bloques tests: embebidos en cada YAML; npm run typecheck corre tsc --noEmit para CI.
src/schemas/rule.schema.json · Draft 2020-12Schema único que valida la forma de toda FaroRule. Define campos obligatorios, enums, patrones de código (RULE-TNS-NNN), referencias internas ($defs/condition) y rangos numéricos. Es la primera línea de defensa antes que cualquier validador cruzado se ejecute.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "FARO Rule Schema MVP",
"type": "object",
"additionalProperties": true,
"required": [
"rule_code", "tension_code", "version", "status",
"name", "description", "scope", "data_requirements",
"conditions", "severity", "output", "tests"
],
"properties": {
"rule_code": { "type": "string", "pattern": "^RULE-TNS-[0-9]{3}$" },
"tension_code": { "type": "string", "pattern": "^TNS-[0-9]{3}$" },
"version": { "type": "integer", "minimum": 1 },
"status": {
"type": "string",
"enum": ["draft", "active", "inactive", "archived"]
},
"name": { "type": "string", "minLength": 3 },
"description": { "type": "string", "minLength": 10 },
"scope": {
"type": "object",
"required": ["company_types", "modules", "frequency", "evaluation_window"],
"properties": {
"company_types": { "type": "array", "items": { "type": "string" }, "minItems": 1 },
"modules": { "type": "array", "items": { "type": "string" }, "minItems": 1 },
"frequency": {
"type": "string",
"enum": ["daily", "weekly", "monthly", "quarterly", "annual", "on_demand"]
},
"evaluation_window": { "type": "string" },
"comparison_window": { "type": "string" },
"dimension": { "type": "string" }
}
},
"data_requirements": {
"type": "object",
"required": [
"required_kpis", "minimum_confidence_score",
"missing_data_policy", "stale_data_policy"
],
"properties": {
"required_kpis": { "type": "array", "items": { "type": "string" }, "minItems": 1 },
"minimum_confidence_score": { "type": "number", "minimum": 0, "maximum": 100 },
"missing_data_policy": {
"type": "string",
"enum": [
"do_not_trigger", "trigger_with_warning",
"create_data_quality_tension", "use_last_available",
"manual_review"
]
},
"stale_data_policy": {
"type": "string",
"enum": ["warn", "do_not_trigger", "use_last_available"]
}
}
},
"conditions": {
"type": "object",
"minProperties": 1,
"properties": {
"all": { "type": "array", "items": { "$ref": "#/$defs/condition" } },
"any": { "type": "array", "items": { "$ref": "#/$defs/condition" } },
"none": { "type": "array", "items": { "$ref": "#/$defs/condition" } }
}
},
"severity": {
"type": "object",
"required": ["default"],
"properties": {
"default": {
"type": "string",
"enum": ["low", "medium", "high", "critical"]
},
"escalation": { "type": "array" }
}
},
"output": {
"type": "object",
"required": [
"create_tension", "title", "diagnosis_template",
"recommended_actions", "assign_to_role",
"evidence_required", "default_sla_days", "score_impact"
],
"properties": {
"create_tension": { "type": "boolean" },
"title": { "type": "string" },
"diagnosis_template": { "type": "string" },
"recommended_actions": { "type": "array", "items": { "type": "string" }, "minItems": 1 },
"assign_to_role": { "type": "string" },
"approver_role": { "type": "string" },
"evidence_required": { "type": "array", "items": { "type": "string" }, "minItems": 1 },
"default_sla_days": { "type": "integer", "minimum": 1 },
"score_impact": {
"type": "object",
"required": ["base"],
"properties": {
"base": { "type": "number" },
"max": { "type": "number" }
}
}
}
},
"tests": { "type": "array", "minItems": 1 }
},
"$defs": {
"condition": {
"type": "object",
"required": ["kpi", "metric", "operator"],
"properties": {
"kpi": { "type": "string" },
"metric": { "type": "string" },
"operator": {
"type": "string",
"enum": [
">", ">=", "<", "<=", "==", "!=",
"between", "in", "not_in",
"exists", "missing",
"changed_by_pct", "older_than_days"
]
},
"value": {}
}
}
}
}
El schema usa la notación DSL kpi.metric.operator.value heredada de FARO-CFG-001. Los 13 operadores listados son la fuente única que también consume operatorRegistry. El patrón RULE-TNS-NNN garantiza convención de naming. additionalProperties: true a nivel raíz permite agregar metadata o governance sin romper el schema base.
validateRuleCada validador es una función pura (rule: FaroRule) => ValidationResult. Devuelven una lista de ValidationIssue con severidad (error/warning/info), código de error y hint accionable. validateRule los orquesta en orden y corta si schema falla.
Compila el schema una sola vez al import, ejecuta validate(rule) por archivo. Convierte cada ajv error en ValidationIssue con path y hint hacia FARO-CFG-001. Es el primer validador y bloqueante: si falla, los demás no se ejecutan.
import Ajv from "ajv"; import addFormats from "ajv-formats"; import schema from "../schemas/rule.schema.json" assert { type: "json" }; import type { ValidationResult } from "../types/validation.types.js"; const ajv = new Ajv({ allErrors: true, strict: false }); addFormats(ajv); const validate = ajv.compile(schema); export function validateSchema(rule: unknown): ValidationResult { const valid = validate(rule); if (valid) return { valid: true, issues: [] }; const issues = validate.errors?.map((error) => ({ severity: "error" as const, code: "SCHEMA_VALIDATION_ERROR", message: `${error.instancePath || "/"} ${error.message}`, path: error.instancePath, hint: "Revisar estructura YAML contra FARO-CFG-001." })) ?? []; return { valid: false, issues }; }
Recorre conditions.all/any/none y cada severity.escalation[].when. Revisa que cada operator esté en SUPPORTED_OPERATORS del registry. Bloquea regla con operador desconocido (típico al copiar reglas viejas con operadores no portados).
import type { FaroRule } from "../types/rule.types.js"; import type { ValidationResult } from "../types/validation.types.js"; import { collectConditions } from "./conditionWalker.js"; import { isSupportedOperator } from "../registry/operatorRegistry.js"; export function validateOperators(rule: FaroRule): ValidationResult { const issues = []; for (const condition of collectConditions(rule.conditions)) { if (!isSupportedOperator(condition.operator)) { issues.push({ severity: "error" as const, code: "UNSUPPORTED_OPERATOR", message: `Operador no soportado: ${condition.operator}`, path: `conditions.${condition.kpi}`, hint: "Usar operadores definidos en FARO-CFG-001." }); } } for (const escalation of rule.severity.escalation ?? []) { for (const condition of collectConditions(escalation.when)) { if (!isSupportedOperator(condition.operator)) { issues.push({ severity: "error" as const, code: "UNSUPPORTED_OPERATOR_ESCALATION", message: `Operador no soportado en escalation: ${condition.operator}`, path: `severity.escalation.${condition.kpi}`, hint: "Usar operadores definidos en FARO-CFG-001." }); } } } return { valid: issues.length === 0, issues }; }
Doble check: cada KPI en data_requirements.required_kpis existe en el registry, y cada condition.kpi está declarado en required_kpis. Hoy el registry es local (MVP_KPI_REGISTRY); cuando FARO-SQL-007 publique faro.kpi_definitions, el registry leerá de DB.
import type { FaroRule } from "../types/rule.types.js"; import type { ValidationResult } from "../types/validation.types.js"; import { kpiExists } from "../registry/kpiRegistry.js"; import { collectConditions } from "./conditionWalker.js"; export function validateKpis(rule: FaroRule): ValidationResult { const issues = []; for (const kpiCode of rule.data_requirements.required_kpis) { if (!kpiExists(kpiCode)) { issues.push({ severity: "error" as const, code: "UNKNOWN_REQUIRED_KPI", message: `KPI requerido no existe en registry: ${kpiCode}`, path: "data_requirements.required_kpis", hint: "Crear KPI en faro.kpi_definitions o corregir código." }); } } for (const condition of collectConditions(rule.conditions)) { if (!rule.data_requirements.required_kpis.includes(condition.kpi)) { issues.push({ severity: "error" as const, code: "CONDITION_KPI_NOT_DECLARED", message: `KPI usado en condición no está declarado en required_kpis: ${condition.kpi}`, path: "conditions", hint: "Agregar el KPI a data_requirements.required_kpis." }); } if (!kpiExists(condition.kpi)) { issues.push({ severity: "error" as const, code: "UNKNOWN_CONDITION_KPI", message: `KPI usado en condición no existe: ${condition.kpi}`, path: "conditions", hint: "Crear KPI o corregir código." }); } } return { valid: issues.length === 0, issues }; }
Cada elemento de output.recommended_actions debe estar en MVP_ACTION_CODES (registry local). Pendiente: leer de faro.action_definitions cuando FARO-SQL-005 publique el catálogo canónico.
import type { FaroRule } from "../types/rule.types.js"; import type { ValidationResult } from "../types/validation.types.js"; import { actionExists } from "../registry/actionRegistry.js"; export function validateActions(rule: FaroRule): ValidationResult { const issues = []; for (const actionCode of rule.output.recommended_actions) { if (!actionExists(actionCode)) { issues.push({ severity: "error" as const, code: "UNKNOWN_ACTION_CODE", message: `Acción recomendada no existe: ${actionCode}`, path: "output.recommended_actions", hint: "Crear acción en biblioteca de acciones o corregir código." }); } } return { valid: issues.length === 0, issues }; }
Verifica output.assign_to_role y, si presente, output.approver_role. Los códigos válidos viven en MVP_ROLE_CODES (8 roles base: commercial / finance / stock / purchasing / general manager + director + data_owner + company_admin). Pendiente leer de faro.roles.
import type { FaroRule } from "../types/rule.types.js"; import type { ValidationResult } from "../types/validation.types.js"; import { roleExists } from "../registry/roleRegistry.js"; export function validateRoles(rule: FaroRule): ValidationResult { const issues = []; if (!roleExists(rule.output.assign_to_role)) { issues.push({ severity: "error" as const, code: "UNKNOWN_ASSIGN_ROLE", message: `Rol de asignación no existe: ${rule.output.assign_to_role}`, path: "output.assign_to_role", hint: "Agregar role mapping o corregir el rol." }); } if (rule.output.approver_role && !roleExists(rule.output.approver_role)) { issues.push({ severity: "error" as const, code: "UNKNOWN_APPROVER_ROLE", message: `Rol aprobador no existe: ${rule.output.approver_role}`, path: "output.approver_role", hint: "Agregar role mapping o corregir el rol." }); } return { valid: issues.length === 0, issues }; }
Doble check: cada EVD-* en output.evidence_required existe en registry (12 evidencias MVP), y el array no puede estar vacío. Toda regla MVP debe exigir evidencia: FARO no premia relatos. Pendiente leer de faro.evidence_definitions (FARO-SQL-006).
import type { FaroRule } from "../types/rule.types.js"; import type { ValidationResult } from "../types/validation.types.js"; import { evidenceExists } from "../registry/evidenceRegistry.js"; export function validateEvidence(rule: FaroRule): ValidationResult { const issues = []; for (const evidenceCode of rule.output.evidence_required) { if (!evidenceExists(evidenceCode)) { issues.push({ severity: "error" as const, code: "UNKNOWN_EVIDENCE_CODE", message: `Código de evidencia no existe: ${evidenceCode}`, path: "output.evidence_required", hint: "Usar evidencia EVD-001..EVD-012 o crear nueva evidencia oficial." }); } } if (rule.output.evidence_required.length === 0) { issues.push({ severity: "error" as const, code: "EVIDENCE_REQUIRED_EMPTY", message: "Toda regla MVP debe exigir evidencia.", path: "output.evidence_required", hint: "Agregar al menos un tipo de evidencia." }); } return { valid: issues.length === 0, issues }; }
La regla debe tener al menos un test que dispare (triggered: true) y otro que no (triggered: false). El primero es error bloqueante; el segundo es warning. Sin test negativo, falsos positivos pasan inadvertidos hasta producción.
import type { FaroRule } from "../types/rule.types.js"; import type { ValidationResult } from "../types/validation.types.js"; export function validateTests(rule: FaroRule): ValidationResult { const issues = []; if (!rule.tests || rule.tests.length === 0) { issues.push({ severity: "error" as const, code: "NO_TESTS", message: "La regla no tiene tests declarativos.", path: "tests", hint: "Agregar al menos un caso que dispara y uno que no dispara." }); return { valid: false, issues }; } const hasPositiveCase = rule.tests.some((test) => test.expect.triggered === true); const hasNegativeCase = rule.tests.some((test) => test.expect.triggered === false); if (!hasPositiveCase) { issues.push({ severity: "error" as const, code: "NO_POSITIVE_TEST", message: "La regla no tiene caso de test que dispare.", path: "tests", hint: "Agregar test con expect.triggered: true." }); } if (!hasNegativeCase) { issues.push({ severity: "warning" as const, code: "NO_NEGATIVE_TEST", message: "La regla no tiene caso negativo.", path: "tests", hint: "Agregar test con expect.triggered: false para evitar falsos positivos." }); } return { valid: !issues.some((i) => i.severity === "error"), issues }; }
Bloquea reglas que intentan ejecutar SQL arbitrario, JavaScript, borrar datos, bypassear RLS o delegar decisiones a IA. Warning si score_impact.base > 0 (una tensión normalmente penaliza Score) o si minimum_confidence_score < 60 (umbral bajo para reglas ejecutivas).
import type { FaroRule } from "../types/rule.types.js"; import type { ValidationResult } from "../types/validation.types.js"; const FORBIDDEN_KEYS = [ "raw_sql", "sql", "eval", "javascript", "function", "delete", "drop_table", "truncate", "bypass_rls", "external_url", "llm_decision" ]; export function validateSecurity(rule: FaroRule): ValidationResult { const issues = []; const serialized = JSON.stringify(rule).toLowerCase(); for (const forbidden of FORBIDDEN_KEYS) { if (serialized.includes(forbidden)) { issues.push({ severity: "error" as const, code: "FORBIDDEN_RULE_CONTENT", message: `La regla contiene contenido prohibido o riesgoso: ${forbidden}`, hint: "Las reglas YAML no pueden ejecutar SQL arbitrario, JS, borrar datos ni llamar IA para decidir." }); } } if (rule.output.score_impact.base > 0) { issues.push({ severity: "warning" as const, code: "POSITIVE_SCORE_IMPACT", message: "Una tensión normalmente penaliza el Score. Revisar score_impact.base positivo.", path: "output.score_impact.base", hint: "Usar impacto negativo para tensiones." }); } if (rule.data_requirements.minimum_confidence_score < 60) { issues.push({ severity: "warning" as const, code: "LOW_CONFIDENCE_THRESHOLD", message: "La confianza mínima es baja para una regla ejecutiva.", path: "data_requirements.minimum_confidence_score", hint: "Recomendado MVP: 70-80." }); } return { valid: !issues.some((i) => i.severity === "error"), issues }; }
validateRuleEjecuta los 8 validadores en orden. Si validateSchema falla, corta porque el resto asume forma válida. Acumula todos los ValidationIssue en una sola respuesta.
import type { FaroRule } from "../types/rule.types.js"; import type { ValidationResult, ValidationIssue } from "../types/validation.types.js"; import { validateSchema } from "./validateSchema.js"; import { validateOperators } from "./validateOperators.js"; import { validateKpis } from "./validateKpis.js"; import { validateActions } from "./validateActions.js"; import { validateRoles } from "./validateRoles.js"; import { validateEvidence } from "./validateEvidence.js"; import { validateTests } from "./validateTests.js"; import { validateSecurity } from "./validateSecurity.js"; export function validateRule(rule: unknown): ValidationResult { const issues: ValidationIssue[] = []; const schemaResult = validateSchema(rule); issues.push(...schemaResult.issues); if (!schemaResult.valid) return { valid: false, issues }; const typedRule = rule as FaroRule; const validators = [ validateOperators, validateKpis, validateActions, validateRoles, validateEvidence, validateTests, validateSecurity ]; for (const validator of validators) { issues.push(...validator(typedRule).issues); } return { valid: !issues.some((i) => i.severity === "error"), issues }; }
validate · import · test:rules · listEl binario faro-rule-parser expone cuatro subcomandos con commander. Cada uno hace una sola cosa: validar reglas, importarlas a DB, ejecutar tests embebidos, o listar reglas activas.
Qué hace. Recorre la carpeta YAML, parsea cada archivo y ejecuta los 8 validadores. No toca DB. Devuelve exit code 1 si hay al menos un error. Ideal para correr en pre-commit y en GitHub Actions.
$ npm run validate # equivalente: $ tsx src/cli.ts validate --path ./rules/mvp # salida esperada: ✅ rules/mvp/commercial/TNS-001_crecimiento_no_rentable.yaml ✅ rules/mvp/finance/TNS-004_venta_sin_caja.yaml Validation finished: 2 files, 0 errors, 0 warnings
Qué hace. Valida + persiste en faro.rule_definitions. Sin --company-id carga como regla global FARO. Con --company-id carga regla específica de empresa. Con --dry-run valida sin tocar DB. Usa withDbContext para setear app.company_id + app.role_codes (RLS).
# Importar reglas globales FARO (company_id = NULL): $ tsx src/cli.ts import --path ./rules/mvp # Importar reglas específicas de Empresa Demo Cuyo S.A.: $ tsx src/cli.ts import \ --path ./rules/mvp/cuyo-overrides \ --company-id 10000000-0000-0000-0000-000000000001 # Modo dry-run (valida, no escribe): $ tsx src/cli.ts import --path ./rules/mvp --dry-run # salida (dry-run): ✅ 11 rules validated Dry-run mode enabled. No rules imported. - RULE-TNS-001 v1 - RULE-TNS-002 v1 ...
Qué hace. Por cada YAML válido, ejecuta los casos del bloque tests: contra evaluateMockConditions (evaluador local que no reemplaza FARO-ENG-003). Reporta pass/fail. Permite TDD declarativo sobre reglas sin tocar DB ni snapshots reales.
$ npm run test:rules # equivalente: $ tsx src/cli.ts test --path ./rules/mvp # salida esperada: ✅ RULE-TNS-001 · dispara_con_crecimiento_no_rentable ✅ RULE-TNS-001 · no_dispara_si_margen_no_cae Rule tests finished: 2 tests, 0 failures
Qué hace. Lista reglas activas en faro.rule_definitions con rule_code, version, status, severity_default y company_id (NULL = global). Útil para auditar qué reglas están vivas en un ambiente y validar diferencias entre staging y producción.
$ tsx src/cli.ts list
# salida esperada (resumen):
RULE-TNS-001 v1 active high [global]
RULE-TNS-002 v1 active high [global]
RULE-TNS-004 v1 active high [global]
RULE-TNS-007 v2 active critical [10000000-0000-0000-0000-000000000001] <- override Cuyo S.A.
...
Total: 12 rules (11 global + 1 company-specific)
src/cli.ts#!/usr/bin/env node import { Command } from "commander"; import dotenv from "dotenv"; import { validateCommand } from "./commands/validateCommand.js"; import { importCommand } from "./commands/importCommand.js"; import { testCommand } from "./commands/testCommand.js"; dotenv.config(); const program = new Command(); program .name("faro-rule-parser") .description("FARO Connect YAML DSL Rule Parser") .version("1.0.0"); program.command("validate") .description("Validate YAML rules") .requiredOption("--path <path>", "Path to YAML rules") .action(async (options) => { await validateCommand(options.path); }); program.command("import") .description("Import YAML rules into database") .requiredOption("--path <path>", "Path to YAML rules") .option("--company-id <companyId>", "Company ID for company-specific rules") .option("--dry-run", "Validate without importing", false) .action(async (options) => { await importCommand({ path: options.path, companyId: options.companyId ?? null, dryRun: options.dryRun }); }); program.command("test") .description("Run declarative tests embedded in YAML rules") .requiredOption("--path <path>", "Path to YAML rules") .action(async (options) => { await testCommand(options.path); }); program.parseAsync(process.argv);
El comando import tiene dos modos. dry-run valida sin tocar DB y reporta qué se cargaría. strict / write persiste en faro.rule_definitions con upsert por (company_id, rule_code, version) respetando RLS.
1console.log de cada ruleCode v<version>Dry-run mode enabled. No rules imported.set_config('app.company_id', $1, true)set_config('app.role_codes', 'faro_owner,company_admin', true)⬆️ Imported RULE-TNS-NNN v<n>✅ Import completedupsertRuleDefinitionEl SQL es un upsert por (company_id, rule_code, version). Soporta tres casos: regla global nueva, regla company-specific nueva, o actualización de regla existente (cambio de name, description, rule_body, severity_default, status). El version nunca se sobrescribe: para cambiar lógica, se sube version en el YAML.
import type pg from "pg"; import type { NormalizedRule } from "../types/validation.types.js"; export async function upsertRuleDefinition( client: pg.PoolClient, params: { companyId: string | null; rule: NormalizedRule } ): Promise<void> { const { companyId, rule } = params; await client.query( ` INSERT INTO faro.rule_definitions ( company_id, rule_code, name, description, rule_type, rule_format, rule_body, severity_default, is_mvp, status, version ) VALUES ( $1, $2, $3, $4, 'tension', 'yaml', $5::jsonb, $6, $7, $8, $9 ) ON CONFLICT (company_id, rule_code, version) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description, rule_body = EXCLUDED.rule_body, severity_default = EXCLUDED.severity_default, is_mvp = EXCLUDED.is_mvp, status = EXCLUDED.status, updated_at = now() `, [ companyId, rule.ruleCode, rule.name, rule.description, JSON.stringify(rule.ruleBody), rule.severityDefault, rule.isMvp, rule.status, rule.version ] ); }
Toda escritura pasa por withDbContext, que envuelve la transacción en BEGIN/COMMIT y setea las variables de sesión que las policies RLS leen (app.company_id, app.user_id, app.role_codes). Si la query falla, hace ROLLBACK automático.
import pg from "pg"; import dotenv from "dotenv"; dotenv.config(); const { Pool } = pg; if (!process.env.DATABASE_URL) { throw new Error("DATABASE_URL is required"); } export const pool = new Pool({ connectionString: process.env.DATABASE_URL }); export async function withDbContext<T>( companyId: string | null, userId: string | null, roleCodes: string[], fn: (client: pg.PoolClient) => Promise<T> ): Promise<T> { const client = await pool.connect(); try { await client.query("BEGIN"); if (companyId) { await client.query(`SELECT set_config('app.company_id', $1, true)`, [companyId]); } if (userId) { await client.query(`SELECT set_config('app.user_id', $1, true)`, [userId]); } await client.query(`SELECT set_config('app.role_codes', $1, true)`, [roleCodes.join(",")]); const result = await fn(client); await client.query("COMMIT"); return result; } catch (error) { await client.query("ROLLBACK"); throw error; } finally { client.release(); } }
El parser se integra a GitHub Actions (o equivalente) bloqueando merge si reglas YAML cambian y no validan. Patrón conceptual:
name: Validate FARO Rules on: pull_request: paths: - "rules/**/*.yaml" - "rules/**/*.yml" - "src/**" jobs: validate-rules: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npm run typecheck - run: npm run validate - run: npm run test:rules
Regla de gobierno: ninguna regla YAML entra a main si no valida y no pasa tests. Esto convierte al parser en gatekeeper real, no en documentación viva.
El validador validateKpis hoy lee de MVP_KPI_REGISTRY (registry local con 13 KPIs hardcodeados). El parser está listo, pero la fuente de verdad real (faro.kpi_definitions) todavía no existe como catálogo canónico publicado. Esto debe cerrarse antes del piloto.
Qué falta. El catálogo formal faro.kpi_definitions con todos los códigos KPI-* que las reglas referencian. Debe incluir: kpi_code, name, area_code, module_code, unit, dimension, aggregation, source_module, refresh_frequency, confidence_baseline, status. Es el cuarto catálogo canónico, paralelo a tensiones (FARO-SQL-004), acciones (FARO-SQL-005) y evidencias (FARO-SQL-006).
Por qué importa. Hoy validateKpis usa un registry local con 13 KPIs (KPI-SAL-001..006, KPI-FIN-001..002, KPI-STK-001..002, KPI-ACT-001..002, KPI-DQ-001). Para soportar las 30 reglas del MVP completo se necesitan al menos 16 códigos adicionales que todavía no existen formalmente. Hasta entonces, una regla puede declarar required_kpis: [KPI-INVENTADO-999] y el parser no lo detecta si el código no estaba en el registry local.
Bloqueo temporal aplicado. Mientras FARO-SQL-007 no exista, validateKpis opera con el registry local. El validador funciona, pero la cobertura es parcial. La pieza reglas-mvp-yaml.html sección 11 declara este mismo gap con código de error UNKNOWN_KPI_CODE. Cuando FARO-SQL-007 publique faro.kpi_definitions, kpiRegistry.ts debe migrarse a lectura desde DB y la validación pasa a cobertura completa.
Acción recomendada. Generar FARO-SQL-007 antes del cierre del MVP. Patrón sugerido: igual que FARO-SQL-004 (catalogo-tensiones-mvp.html), con DDL V027__create_kpi_definitions.sql + seed V028__seed_kpi_definitions_mvp.sql. Después agregar pieza catalogo-kpis-mvp.html al pack NDA con la misma estructura visual que este documento. Una vez disponible, migrar src/registry/kpiRegistry.ts de MVP_KPI_REGISTRY (array local) a query parametrizada contra faro.kpi_definitions con cache de 60s.
El mismo patrón aplica a los demás registries locales. La v1.1 del parser debería migrar:
faro.action_definitions cuando FARO-SQL-005 publique catálogo canónico de 15 acciones MVP (ACT-COM-001..ACT-DIR-001).faro.roles cuando se publique el catálogo formal de roles. Hoy hardcoded a 8 roles base.faro.evidence_definitions cuando FARO-SQL-006 publique el catálogo canónico de 12 evidencias MVP (EVD-001..EVD-012).El parser es la bisagra entre el DSL YAML (FARO-CFG-001), los catálogos canónicos (tensiones / acciones / evidencias / KPIs) y el motor evaluador (FARO-ENG-003). Estos son los puntos de cruce con cada pieza del pack NDA.
FARO-CFG-001 · especificación del DSL YAML que este parser lee. Los 13 operadores, las 10 secciones y los campos obligatorios se definen ahí.
Lee faro.rule_definitions (cargado por este parser), aplica snapshots de KPIs, crea tensiones y acciones. Pendiente de construcción.
30 tensiones canónicas TNS-001..TNS-030. El campo tension_code del YAML debe existir aquí con status = active.
Catálogo canónico MVP de 15 acciones (ACT-COM-001..ACT-DIR-001). Resolución de output.recommended_actions del YAML.
Catálogo canónico MVP de 12 evidencias (EVD-001..EVD-012). Resolución de output.evidence_required del YAML.
DDL completo. Incluye faro.rule_definitions (target de upsertRuleDefinition) y faro.rule_evaluations (escrito por el motor).
Framework de tests declarativos sobre el bloque tests: de cada YAML. Reusa ruleTestRunner del parser. Pendiente de construcción.
Este scaffold deja @faro/rule-parser v1.0 documentado, validado y testeable. Cerrar FARO-SQL-007 (catálogo de KPIs) habilita la cadena completa. El siguiente paso natural es FARO-ENG-003 (motor evaluador): ahí FARO empieza a caminar solo.