01 · Resumen ejecutivo

El parser convierte YAML en configuración ejecutable, validada y auditable

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.

▸ pipeline FARO-ENG-002 · alto nivel
  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).

02 · Tesis técnica y 5 funciones críticas

FARO Connect no debe tener reglas hardcodeadas

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.

Función 01

Lectura

Carga archivos YAML desde una carpeta o archivo individual con fileWalker (glob) y loadYaml (paquete yaml). Soporta árboles anidados rules/mvp/<area>/*.yaml.

Función 02

Validación

Revisa estructura, campos obligatorios, tipos y operadores contra rule.schema.json con Ajv, y aplica validadores cruzados para condiciones, escalation y seguridad.

Función 03

Gobernanza

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.

Función 04

Normalización

Convierte YAML a JSON interno consistente con NormalizedRule: extrae rule_code, version, status a columnas y serializa el resto en ruleBody jsonb.

Función 05

Persistencia

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.

03 · Alcance

Qué hace · qué NO hace (frontera con motor evaluador)

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.

▸ Sí incluye (FARO-ENG-002)
  • Parser YAML · lectura archivo o carpeta
  • Validación JSON Schema · rule.schema.json
  • Validación de operadores · 13 operadores soportados
  • Validación de KPIs · existencia + declaración cruzada
  • Validación de acciones · ACT-* en registry
  • Validación de roles · assign + approver
  • Validación de evidencias · EVD-* obligatorio
  • Validación de tests declarativos · positivo + negativo
  • Validación de seguridad · bloqueo de keys prohibidas
  • Conversión YAML → JSON · normalizeRule()
  • CLI técnico · validate / import / test / list
  • Carga a PostgreSQL · upsertRuleDefinition
  • Reporte de errores · ValidationIssue con hint
  • Modo dry-run · valida sin escribir DB
  • Modo strict · aborta en warning
  • Reglas globales y por cliente · company_id opcional
  • Test runner base · ejecutor mock de tests YAML
▸ No incluye (otros activos)
  • Evaluar reglas contra KPIs reales · FARO-ENG-003
  • Cargar kpi_snapshots · FARO-ENG-003
  • Crear tensiones reales · FARO-ENG-003
  • Crear acciones reales · FARO-ENG-003
  • Pedir evidencias en UI · FARO-UI-001 · Bandeja
  • Alimentar FARO Score · FARO-ENG-003 + FARO-SCORE
  • UI de edición de reglas · FARO-UI-ADMIN (fase posterior)
  • IA explicativa · FARO-AI (fase posterior)
  • Recalibración automática · FARO-LEARN (fase aprendizaje)
  • Templates email / reporte · FARO-TPL-001 / FARO-TPL-002
  • Notificaciones de disparo · FARO-NOTIF

Por 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).

04 · Stack recomendado

TypeScript + Ajv + Zod + pg + Commander

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ó
typescriptTipado estrictoTipos para FaroRule, ValidationResult, autodocumentación, IDE help. strict: true obligatorio.
tsxEjecutar TS en devSin paso de build en dev. CLI corre directo. Menor fricción para iterar reglas.
yamlLeer YAMLParser maduro, mantiene comentarios, soporta anchors. Más confiable que js-yaml legacy.
ajvValidar JSON SchemaCompilador JIT, error reporting detallado, draft 2020-12 soportado. Estándar de facto.
ajv-formatsFormatos adicionalesUUID, email, date-time, uri. Necesario para metadata que use formatos estándar.
zodValidación runtime opcionalTipos en runtime para inputs de tests YAML (record dinámico). Complemento de Ajv para casos no-schema.
pgPostgreSQL driverCliente oficial. Soporta jsonb, transacciones, set_config para RLS.
commanderCLISubcomandos limpios, parseo de flags, help automático. Lo usa el resto del ecosistema FARO.
globBuscar archivosPatrones **/*.yaml para recorrer árbol de reglas. v11 con API promise nativa.
dotenvVariables de entornoCarga .env en dev. Producción usa env del proceso (Docker, k8s, Vercel).
vitestTests de frameworkMá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.

05 · Arquitectura del módulo

Estructura de carpetas 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/ · árbol completo
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.

06 · package.json + tsconfig + scripts

Configuración base del módulo @faro/rule-parser

Tres 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).

▸ package.json · @faro/rule-parser v1.0.0
{
  "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"
  }
}
▸ tsconfig.json · strict mode + ES2022
{
  "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"]
}
▸ .env.example · contrato de variables
# 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.

07 · JSON Schema completo de regla

src/schemas/rule.schema.json · Draft 2020-12

Schema ú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.

▸ src/schemas/rule.schema.json · FARO Rule Schema MVP
{
  "$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.

08 · Validadores TypeScript

8 validadores cruzados · orquestador validateRule

Cada 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.

Validador 01 · validateSchema SCHEMA_VALIDATION_ERROR

Forma de la regla con Ajv + rule.schema.json

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.

▸ src/validators/validateSchema.ts
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 };
}
Validador 02 · validateOperators UNSUPPORTED_OPERATOR

Operadores en conditions y en escalation

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).

▸ src/validators/validateOperators.ts
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 };
}
Validador 03 · validateKpis UNKNOWN_REQUIRED_KPI / CONDITION_KPI_NOT_DECLARED

KPIs requeridos existen y están declarados PENDIENTE · requiere FARO-SQL-007

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.

▸ src/validators/validateKpis.ts
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 };
}
Validador 04 · validateActions UNKNOWN_ACTION_CODE

Acciones recomendadas existen en biblioteca

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.

▸ src/validators/validateActions.ts
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 };
}
Validador 05 · validateRoles UNKNOWN_ASSIGN_ROLE / UNKNOWN_APPROVER_ROLE

Roles assign y approver existen

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.

▸ src/validators/validateRoles.ts
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 };
}
Validador 06 · validateEvidence UNKNOWN_EVIDENCE_CODE / EVIDENCE_REQUIRED_EMPTY

Evidencia obligatoria y existente

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).

▸ src/validators/validateEvidence.ts
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 };
}
Validador 07 · validateTests NO_TESTS / NO_POSITIVE_TEST / NO_NEGATIVE_TEST

Tests declarativos positivo + negativo

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.

▸ src/validators/validateTests.ts
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 };
}
Validador 08 · validateSecurity FORBIDDEN_RULE_CONTENT / POSITIVE_SCORE_IMPACT / LOW_CONFIDENCE_THRESHOLD

Bloqueo de contenido peligroso y warnings de gobierno

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).

▸ src/validators/validateSecurity.ts
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 };
}

Orquestador validateRule

Ejecuta los 8 validadores en orden. Si validateSchema falla, corta porque el resto asume forma válida. Acumula todos los ValidationIssue en una sola respuesta.

▸ src/validators/validateRule.ts · orquestador principal
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 };
}
09 · CLI · 4 comandos del parser

validate · import · test:rules · list

El 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.

faro-rule-parser validate --path <path> CI · sin DB

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.

▸ uso · validar carpeta mvp
$ 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
faro-rule-parser import --path <path> [--company-id <uuid>] [--dry-run] deploy · escribe DB

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).

▸ uso · reglas globales + Empresa Demo Cuyo S.A.
# 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
   ...
faro-rule-parser test --path <path> QA · evaluator mock

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.

▸ uso · correr tests declarativos
$ 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
faro-rule-parser list inspección · DB

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.

▸ uso · inspección rápida
$ 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)

Entrypoint src/cli.ts

▸ src/cli.ts · commander wiring
#!/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);
10 · Flujo de import

Dos modos · dry-run vs strict / write

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.

▸ modo dry-run (default en .env)

Validar y reportar · sin escribir

  1. findYamlFiles(path) recorre el árbol con glob
  2. parseRuleFile(file) por cada archivo: loadYaml + validateRule
  3. Si alguna regla tiene error: abortar con exit code 1
  4. Si todas válidas: console.log de cada ruleCode v<version>
  5. Mensaje final: Dry-run mode enabled. No rules imported.
  6. No abre conexión a DB. No setea RLS. No bloquea ningún recurso.
▸ modo strict / write

Validar y persistir · con RLS

  1. findYamlFiles(path) + parseRuleFile (igual que dry-run)
  2. Si alguna regla tiene error: abortar antes de tocar DB
  3. withDbContext(companyId, null, [faro_owner, company_admin], ...)
  4. BEGIN + set_config('app.company_id', $1, true)
  5. set_config('app.role_codes', 'faro_owner,company_admin', true)
  6. Por cada regla: upsertRuleDefinition (ON CONFLICT actualiza)
  7. COMMIT (o ROLLBACK si falla cualquiera). Mensaje: ⬆️ Imported RULE-TNS-NNN v<n>
  8. Mensaje final: ✅ Import completed

Repositorio de reglas · upsertRuleDefinition

El 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.

▸ src/db/ruleRepository.ts · upsert
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
    ]
  );
}

Conexión DB con contexto RLS

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.

▸ src/db/db.ts · withDbContext
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();
  }
}

Regla de gobierno en CI/CD

El parser se integra a GitHub Actions (o equivalente) bloqueando merge si reglas YAML cambian y no validan. Patrón conceptual:

▸ .github/workflows/validate-rules.yml · 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.

11 · Gap detectado · catálogo KPIs

validateKpis requiere FARO-SQL-007 para cerrar la cadena

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.

▸ GAP CONOCIDO PENDIENTE · requiere FARO-SQL-007

FARO-SQL-007 · Catálogo Canónico de KPIs MVP

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.

Otros registries pendientes de migración a DB

El mismo patrón aplica a los demás registries locales. La v1.1 del parser debería migrar:

  • actionRegistry → leer de faro.action_definitions cuando FARO-SQL-005 publique catálogo canónico de 15 acciones MVP (ACT-COM-001..ACT-DIR-001).
  • roleRegistry → leer de faro.roles cuando se publique el catálogo formal de roles. Hoy hardcoded a 8 roles base.
  • evidenceRegistry → leer de faro.evidence_definitions cuando FARO-SQL-006 publique el catálogo canónico de 12 evidencias MVP (EVD-001..EVD-012).
  • operatorRegistry → permanece local (es spec del DSL, no de datos). Cambios al registry implican cambio del DSL (versión nueva del parser).
12 · Cross-references

Cómo se cruza el parser con el resto del pack

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.

Próximos pasos · roadmap inmediato

  1. Implementar repo @faro/rule-parser v1.0. Bootstrappear con package.json + tsconfig.json + estructura de carpetas de sección 5. Copiar los 8 validadores y registry mock de las secciones 7-8. Test de smoke con 1 regla válida + 1 inválida en tests/fixtures/.
  2. Conectar a Postgres local (Empresa Demo Cuyo S.A.). Crear faro.rule_definitions con migración de FARO-SQL-001. Probar import --dry-run contra las 11 reglas MVP de reglas-mvp-yaml.html. Validar que el upsert respeta RLS.
  3. Cerrar gap FARO-SQL-007. PENDIENTE Generar catálogo canónico de KPIs MVP siguiendo patrón de FARO-SQL-004. Migrar kpiRegistry.ts de array local a query contra faro.kpi_definitions. Validación pasa de parcial a completa.
  4. Integrar workflow CI. Agregar .github/workflows/validate-rules.yml con job que corre npm run typecheck && npm run validate && npm run test:rules. Bloquear merge a main si falla.
  5. FARO-ENG-003 · Motor Evaluador MVP. Construir motor que lee faro.rule_definitions activas, carga snapshots de faro.kpi_snapshots, evalúa condiciones, calcula severidad, registra evaluación en faro.rule_evaluations, crea tensiones en faro.tensions y acciones en faro.actions.
  6. FARO-TEST-002 · Tests Reglas MVP. Framework que reusa ruleTestRunner del parser para correr el bloque tests: de cada YAML en CI continuo. Reporte por área (commercial, finance, stock, execution, data_quality).
  7. Migrar resto de registries a DB. Cuando FARO-SQL-005 (acciones) y FARO-SQL-006 (evidencias) estén publicados, migrar actionRegistry.ts y evidenceRegistry.ts a lectura desde DB con cache de 60s. Eliminar arrays hardcodeados.
  8. v1.1 mejoras posteriores. Agregar modo explain (muestra qué condiciones se evaluarían), comando diff entre versiones de regla, firma/aprobación de regla, UI admin de reglas y simulación contra dataset histórico.