# FARO-ENG-002 · DSL Parser Reglas YAML

**Código:** FARO-ENG-002
**Nombre:** DSL Parser Reglas YAML FARO Connect
**Versión:** v1.0
**Estado:** Scaffold técnico inicial
**Prioridad:** P1 · Crítico para motor de reglas MVP
**Lenguaje recomendado:** TypeScript / Node.js
**Formato:** Repo / módulo backend / CLI técnico
**Depende de:**

* FARO-CFG-001 · Reglas MVP en YAML
* FARO-SQL-001 · Migraciones Base MVP
* FARO-SQL-002 · Multiempresa, Roles y RLS
* FARO-SQL-003 · Seeds Empresa Demo

**Conecta con:**

* FARO-ENG-003 · Motor Evaluador MVP
* FARO-TEST-002 · Tests Reglas MVP
* FARO-UI-001 · Bandeja de Tensiones
* FARO-TPL-001 · Templates Email Alertas
* FARO-TPL-002 · Reporte Semanal Ejecutivo

---

# 1. Objetivo

El objetivo de FARO-ENG-002 es construir el parser técnico que permita tomar reglas FARO escritas en YAML, validarlas, convertirlas a JSON ejecutable y guardarlas en la base de datos para que el motor evaluador pueda usarlas.

El flujo esperado es:

```text
Archivo YAML
→ lectura
→ parsing
→ validación de estructura
→ validación de operadores
→ validación de KPIs requeridos
→ validación de acciones sugeridas
→ validación de roles
→ validación de evidencia
→ validación de tests
→ conversión a JSON normalizado
→ persistencia en faro.rule_definitions
→ reporte de errores o éxito
```

La regla de negocio es clara:

```text
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.

---

# 2. Tesis técnica

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:

| Función       | Descripción                                                |
| ------------- | ---------------------------------------------------------- |
| Lectura       | Carga archivos YAML desde una carpeta o archivo individual |
| Validación    | Revisa estructura, campos obligatorios, tipos y operadores |
| Gobernanza    | Verifica que KPIs, acciones, roles y evidencias existan    |
| Normalización | Convierte YAML a JSON interno consistente                  |
| Persistencia  | Guarda reglas en `faro.rule_definitions`                   |

Este parser no evalúa la regla contra datos reales.
Eso corresponde a **FARO-ENG-003 · Motor Evaluador MVP**.

---

# 3. Alcance

## 3.1 Incluye

| Componente                            | Incluido |
| ------------------------------------- | -------: |
| Parser YAML                           |       Sí |
| Validación JSON Schema                |       Sí |
| Validación de operadores              |       Sí |
| Validación de KPIs                    |       Sí |
| Validación de acciones                |       Sí |
| Validación de roles                   |       Sí |
| Validación de evidencias              |       Sí |
| Validación de tests declarativos      |       Sí |
| Conversión YAML → JSON                |       Sí |
| CLI técnico                           |       Sí |
| Carga a PostgreSQL                    |       Sí |
| Reporte de errores                    |       Sí |
| Modo dry-run                          |       Sí |
| Modo strict                           |       Sí |
| Soporte reglas globales y por cliente |       Sí |
| Test runner base                      |       Sí |

## 3.2 No incluye

| Elemento                          | Motivo                     | Próximo activo |
| --------------------------------- | -------------------------- | -------------- |
| Evaluar reglas contra KPIs reales | Lo hace el motor evaluador | FARO-ENG-003   |
| Crear tensiones reales            | Lo hace el motor evaluador | FARO-ENG-003   |
| Crear acciones reales             | Lo hace el motor evaluador | FARO-ENG-003   |
| UI de edición de reglas           | Fase posterior             | FARO-UI-ADMIN  |
| IA explicativa                    | Fase posterior             | FARO-AI        |
| Recalibración automática          | Fase aprendizaje           | FARO-LEARN     |

---

# 4. Arquitectura del módulo

```text
faro-rule-parser/
  package.json
  tsconfig.json
  .env.example
  README.md

  src/
    index.ts
    cli.ts

    config/
      parser.config.ts

    schemas/
      rule.schema.json

    types/
      rule.types.ts
      validation.types.ts

    parser/
      loadYaml.ts
      parseRuleFile.ts
      normalizeRule.ts

    validators/
      validateSchema.ts
      validateOperators.ts
      validateKpis.ts
      validateActions.ts
      validateRoles.ts
      validateEvidence.ts
      validateTests.ts
      validateSecurity.ts
      validateRule.ts

    registry/
      kpiRegistry.ts
      actionRegistry.ts
      roleRegistry.ts
      evidenceRegistry.ts
      operatorRegistry.ts

    db/
      db.ts
      ruleRepository.ts

    commands/
      validateCommand.ts
      importCommand.ts
      testCommand.ts
      listCommand.ts

    testing/
      ruleTestRunner.ts
      evaluateMockConditions.ts

    utils/
      logger.ts
      errors.ts
      fileWalker.ts

  rules/
    mvp/
      commercial/
      finance/
      stock/
      execution/
      data_quality/

  tests/
    fixtures/
      valid-rule.yaml
      invalid-rule.yaml
```

---

# 5. Stack recomendado

## 5.1 Dependencias principales

| Paquete       | Uso                         |
| ------------- | --------------------------- |
| `typescript`  | Tipado                      |
| `tsx`         | Ejecutar TS en desarrollo   |
| `yaml`        | Leer YAML                   |
| `ajv`         | Validar JSON Schema         |
| `ajv-formats` | Formatos adicionales        |
| `zod`         | Validación runtime opcional |
| `pg`          | PostgreSQL                  |
| `commander`   | CLI                         |
| `glob`        | Buscar archivos             |
| `dotenv`      | Variables entorno           |
| `vitest`      | Tests                       |

---

# 6. package.json

```json
{
  "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"
  }
}
```

---

# 7. tsconfig.json

```json
{
  "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"]
}
```

---

# 8. .env.example

```env
DATABASE_URL=postgresql://faro_app:password@localhost:5432/faro
FARO_RULES_PATH=./rules/mvp
FARO_IMPORT_MODE=dry-run
FARO_DEFAULT_COMPANY_ID=
FARO_STRICT_MODE=true
```

---

# 9. Tipos principales

## `src/types/rule.types.ts`

```ts
export type RuleStatus = "draft" | "active" | "inactive" | "archived";

export type RuleFrequency = "daily" | "weekly" | "monthly" | "quarterly" | "annual" | "on_demand";

export type MissingDataPolicy =
  | "do_not_trigger"
  | "trigger_with_warning"
  | "create_data_quality_tension"
  | "use_last_available"
  | "manual_review";

export type StaleDataPolicy = "warn" | "do_not_trigger" | "use_last_available";

export type RuleOperator =
  | ">"
  | ">="
  | "<"
  | "<="
  | "=="
  | "!="
  | "between"
  | "in"
  | "not_in"
  | "exists"
  | "missing"
  | "changed_by_pct"
  | "older_than_days";

export type Severity = "low" | "medium" | "high" | "critical";

export type Condition = {
  kpi: string;
  metric: string;
  operator: RuleOperator;
  value?: number | string | boolean | Array<string | number>;
};

export type ConditionGroup = {
  all?: Condition[];
  any?: Condition[];
  none?: Condition[];
};

export type RuleScope = {
  company_types: string[];
  modules: string[];
  frequency: RuleFrequency;
  evaluation_window: string;
  comparison_window?: string;
  dimension?: string;
};

export type DataRequirements = {
  required_kpis: string[];
  minimum_confidence_score: number;
  missing_data_policy: MissingDataPolicy;
  stale_data_policy: StaleDataPolicy;
};

export type SeverityEscalation = {
  when: ConditionGroup;
  set: Severity;
};

export type SeverityConfig = {
  default: Severity;
  escalation?: SeverityEscalation[];
};

export type RuleOutput = {
  create_tension: boolean;
  title: string;
  diagnosis_template: string;
  recommended_actions: string[];
  assign_to_role: string;
  approver_role?: string;
  evidence_required: string[];
  default_sla_days: number;
  score_impact: {
    base: number;
    max?: number;
  };
};

export type RuleTestCase = {
  name: string;
  input: Record<string, Record<string, unknown>>;
  expect: {
    triggered: boolean;
    severity?: Severity;
    tension_code?: string;
    reason?: string;
  };
};

export type FaroRule = {
  rule_code: string;
  tension_code: string;
  version: number;
  status: RuleStatus;
  name: string;
  description: string;
  scope: RuleScope;
  data_requirements: DataRequirements;
  conditions: ConditionGroup;
  severity: SeverityConfig;
  output: RuleOutput;
  tests: RuleTestCase[];
  metadata?: Record<string, unknown>;
};
```

---

# 10. Tipos de validación

## `src/types/validation.types.ts`

```ts
export type ValidationSeverity = "error" | "warning" | "info";

export type ValidationIssue = {
  severity: ValidationSeverity;
  code: string;
  message: string;
  path?: string;
  hint?: string;
};

export type ValidationResult = {
  valid: boolean;
  issues: ValidationIssue[];
};

export type ParsedRuleFile = {
  filePath: string;
  rule: unknown;
};

export type NormalizedRule = {
  ruleCode: string;
  tensionCode: string;
  version: number;
  status: string;
  name: string;
  description: string;
  severityDefault: string;
  isMvp: boolean;
  ruleBody: Record<string, unknown>;
};
```

---

# 11. JSON Schema

## `src/schemas/rule.schema.json`

```json
{
  "$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": {}
      }
    }
  }
}
```

---

# 12. Registry de operadores

## `src/registry/operatorRegistry.ts`

```ts
import type { RuleOperator } from "../types/rule.types.js";

export const SUPPORTED_OPERATORS: RuleOperator[] = [
  ">",
  ">=",
  "<",
  "<=",
  "==",
  "!=",
  "between",
  "in",
  "not_in",
  "exists",
  "missing",
  "changed_by_pct",
  "older_than_days",
];

export function isSupportedOperator(operator: string): operator is RuleOperator {
  return SUPPORTED_OPERATORS.includes(operator as RuleOperator);
}
```

---

# 13. Registry de KPIs

En MVP puede empezar como archivo local. Luego debe consultar `faro.kpi_definitions`.

## `src/registry/kpiRegistry.ts`

```ts
export type KpiDefinitionRef = {
  kpiCode: string;
  name: string;
  moduleCode: string;
  isMvp: boolean;
};

export const MVP_KPI_REGISTRY: KpiDefinitionRef[] = [
  { kpiCode: "KPI-SAL-001", name: "Ventas netas", moduleCode: "sales", isMvp: true },
  { kpiCode: "KPI-SAL-002", name: "Margen bruto", moduleCode: "sales", isMvp: true },
  { kpiCode: "KPI-SAL-003", name: "Descuento promedio", moduleCode: "sales", isMvp: true },
  { kpiCode: "KPI-SAL-004", name: "Venta por vendedor", moduleCode: "sales", isMvp: true },
  { kpiCode: "KPI-SAL-005", name: "Margen por vendedor", moduleCode: "sales", isMvp: true },
  { kpiCode: "KPI-SAL-006", name: "Descuento por vendedor", moduleCode: "sales", isMvp: true },
  { kpiCode: "KPI-FIN-001", name: "Días de cobranza", moduleCode: "receivables", isMvp: true },
  { kpiCode: "KPI-FIN-002", name: "Mora vencida", moduleCode: "receivables", isMvp: true },
  { kpiCode: "KPI-STK-001", name: "Stock crítico", moduleCode: "stock", isMvp: true },
  { kpiCode: "KPI-STK-002", name: "Stock inmovilizado", moduleCode: "stock", isMvp: true },
  { kpiCode: "KPI-ACT-001", name: "Acciones vencidas", moduleCode: "actions", isMvp: true },
  { kpiCode: "KPI-ACT-002", name: "Acciones sin evidencia", moduleCode: "actions", isMvp: true },
  { kpiCode: "KPI-DQ-001", name: "Score calidad de datos", moduleCode: "data_quality", isMvp: true }
];

export function kpiExists(kpiCode: string): boolean {
  return MVP_KPI_REGISTRY.some((kpi) => kpi.kpiCode === kpiCode);
}
```

---

# 14. Registry de acciones

## `src/registry/actionRegistry.ts`

```ts
export const MVP_ACTION_CODES = [
  "ACT-COM-001",
  "ACT-COM-002",
  "ACT-COM-003",
  "ACT-COM-004",
  "ACT-FIN-001",
  "ACT-FIN-002",
  "ACT-FIN-003",
  "ACT-STK-001",
  "ACT-STK-002",
  "ACT-STK-003",
  "ACT-PUR-001",
  "ACT-OPS-001",
  "ACT-OPS-002",
  "ACT-DQ-001",
  "ACT-DIR-001"
];

export function actionExists(actionCode: string): boolean {
  return MVP_ACTION_CODES.includes(actionCode);
}
```

---

# 15. Registry de roles

## `src/registry/roleRegistry.ts`

```ts
export const MVP_ROLE_CODES = [
  "commercial_manager",
  "finance_manager",
  "stock_manager",
  "purchasing_manager",
  "general_manager",
  "director",
  "data_owner",
  "company_admin"
];

export function roleExists(roleCode: string): boolean {
  return MVP_ROLE_CODES.includes(roleCode);
}
```

---

# 16. Registry de evidencias

## `src/registry/evidenceRegistry.ts`

```ts
export const MVP_EVIDENCE_CODES = [
  "EVD-001",
  "EVD-002",
  "EVD-003",
  "EVD-004",
  "EVD-005",
  "EVD-006",
  "EVD-007",
  "EVD-008",
  "EVD-009",
  "EVD-010",
  "EVD-011",
  "EVD-012"
];

export function evidenceExists(evidenceCode: string): boolean {
  return MVP_EVIDENCE_CODES.includes(evidenceCode);
}
```

---

# 17. Cargar YAML

## `src/parser/loadYaml.ts`

```ts
import fs from "node:fs/promises";
import YAML from "yaml";

export async function loadYamlFile(filePath: string): Promise<unknown> {
  const raw = await fs.readFile(filePath, "utf8");

  try {
    return YAML.parse(raw);
  } catch (error) {
    throw new Error(`Error parsing YAML file ${filePath}: ${(error as Error).message}`);
  }
}
```

---

# 18. Buscar archivos

## `src/utils/fileWalker.ts`

```ts
import { glob } from "glob";

export async function findYamlFiles(path: string): Promise<string[]> {
  const patterns = [
    `${path.replace(/\/$/, "")}/**/*.yaml`,
    `${path.replace(/\/$/, "")}/**/*.yml`
  ];

  const files = new Set<string>();

  for (const pattern of patterns) {
    const matches = await glob(pattern, { nodir: true });
    matches.forEach((file) => files.add(file));
  }

  return Array.from(files).sort();
}
```

---

# 19. Validar schema

## `src/validators/validateSchema.ts`

```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
  };
}
```

---

# 20. Utilidad para recorrer condiciones

## `src/validators/conditionWalker.ts`

```ts
import type { Condition, ConditionGroup } from "../types/rule.types.js";

export function collectConditions(group: ConditionGroup): Condition[] {
  return [
    ...(group.all ?? []),
    ...(group.any ?? []),
    ...(group.none ?? [])
  ];
}
```

---

# 21. Validar operadores

## `src/validators/validateOperators.ts`

```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 = [];

  const mainConditions = collectConditions(rule.conditions);

  for (const condition of mainConditions) {
    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 ?? []) {
    const escalationConditions = collectConditions(escalation.when);

    for (const condition of escalationConditions) {
      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
  };
}
```

---

# 22. Validar KPIs

## `src/validators/validateKpis.ts`

```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."
      });
    }
  }

  const conditions = collectConditions(rule.conditions);

  for (const condition of 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
  };
}
```

---

# 23. Validar acciones

## `src/validators/validateActions.ts`

```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
  };
}
```

---

# 24. Validar roles

## `src/validators/validateRoles.ts`

```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
  };
}
```

---

# 25. Validar evidencia

## `src/validators/validateEvidence.ts`

```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
  };
}
```

---

# 26. Validar seguridad

## `src/validators/validateSecurity.ts`

```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((issue) => issue.severity === "error"),
    issues
  };
}
```

---

# 27. Validar tests

## `src/validators/validateTests.ts`

```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((issue) => issue.severity === "error"),
    issues
  };
}
```

---

# 28. Validador principal

## `src/validators/validateRule.ts`

```ts
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) {
    const result = validator(typedRule);
    issues.push(...result.issues);
  }

  return {
    valid: !issues.some((issue) => issue.severity === "error"),
    issues
  };
}
```

---

# 29. Normalizar regla

## `src/parser/normalizeRule.ts`

```ts
import type { FaroRule } from "../types/rule.types.js";
import type { NormalizedRule } from "../types/validation.types.js";

export function normalizeRule(rule: FaroRule): NormalizedRule {
  return {
    ruleCode: rule.rule_code,
    tensionCode: rule.tension_code,
    version: rule.version,
    status: rule.status,
    name: rule.name,
    description: rule.description,
    severityDefault: rule.severity.default,
    isMvp: true,
    ruleBody: {
      ...rule,
      normalized_at: new Date().toISOString(),
      parser_version: "FARO-ENG-002-v1.0"
    }
  };
}
```

---

# 30. Parsear archivo individual

## `src/parser/parseRuleFile.ts`

```ts
import type { FaroRule } from "../types/rule.types.js";
import { loadYamlFile } from "./loadYaml.js";
import { validateRule } from "../validators/validateRule.js";
import { normalizeRule } from "./normalizeRule.js";

export async function parseRuleFile(filePath: string) {
  const rawRule = await loadYamlFile(filePath);
  const validation = validateRule(rawRule);

  if (!validation.valid) {
    return {
      filePath,
      valid: false,
      issues: validation.issues,
      normalizedRule: null
    };
  }

  const normalizedRule = normalizeRule(rawRule as FaroRule);

  return {
    filePath,
    valid: true,
    issues: validation.issues,
    normalizedRule
  };
}
```

---

# 31. Conexión DB

## `src/db/db.ts`

```ts
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();
  }
}
```

---

# 32. Repositorio de reglas

## `src/db/ruleRepository.ts`

```ts
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
    ]
  );
}
```

---

# 33. Comando validate

## `src/commands/validateCommand.ts`

```ts
import { findYamlFiles } from "../utils/fileWalker.js";
import { parseRuleFile } from "../parser/parseRuleFile.js";

export async function validateCommand(path: string): Promise<void> {
  const files = await findYamlFiles(path);

  if (files.length === 0) {
    console.log(`No YAML files found in ${path}`);
    process.exitCode = 1;
    return;
  }

  let errorCount = 0;
  let warningCount = 0;

  for (const file of files) {
    const result = await parseRuleFile(file);

    if (result.valid) {
      console.log(`✅ ${file}`);
    } else {
      console.log(`❌ ${file}`);
    }

    for (const issue of result.issues) {
      if (issue.severity === "error") errorCount++;
      if (issue.severity === "warning") warningCount++;

      console.log(`   [${issue.severity.toUpperCase()}] ${issue.code}: ${issue.message}`);

      if (issue.hint) {
        console.log(`      Hint: ${issue.hint}`);
      }
    }
  }

  console.log("");
  console.log(`Validation finished: ${files.length} files, ${errorCount} errors, ${warningCount} warnings`);

  if (errorCount > 0) {
    process.exitCode = 1;
  }
}
```

---

# 34. Comando import

## `src/commands/importCommand.ts`

```ts
import { findYamlFiles } from "../utils/fileWalker.js";
import { parseRuleFile } from "../parser/parseRuleFile.js";
import { withDbContext } from "../db/db.js";
import { upsertRuleDefinition } from "../db/ruleRepository.js";

export async function importCommand(params: {
  path: string;
  companyId: string | null;
  dryRun: boolean;
}): Promise<void> {
  const files = await findYamlFiles(params.path);

  if (files.length === 0) {
    console.log(`No YAML files found in ${params.path}`);
    process.exitCode = 1;
    return;
  }

  const parsed = [];

  for (const file of files) {
    const result = await parseRuleFile(file);

    if (!result.valid || !result.normalizedRule) {
      console.log(`❌ Cannot import invalid rule: ${file}`);
      for (const issue of result.issues) {
        console.log(`   [${issue.severity.toUpperCase()}] ${issue.code}: ${issue.message}`);
      }
      process.exitCode = 1;
      return;
    }

    parsed.push(result);
  }

  console.log(`✅ ${parsed.length} rules validated`);

  if (params.dryRun) {
    console.log("Dry-run mode enabled. No rules imported.");
    for (const item of parsed) {
      console.log(`   - ${item.normalizedRule?.ruleCode} v${item.normalizedRule?.version}`);
    }
    return;
  }

  await withDbContext(
    params.companyId,
    null,
    ["faro_owner", "company_admin"],
    async (client) => {
      for (const item of parsed) {
        if (!item.normalizedRule) continue;

        await upsertRuleDefinition(client, {
          companyId: params.companyId,
          rule: item.normalizedRule
        });

        console.log(`⬆️ Imported ${item.normalizedRule.ruleCode} v${item.normalizedRule.version}`);
      }
    }
  );

  console.log("✅ Import completed");
}
```

---

# 35. Evaluador mock para tests

Este evaluador no reemplaza el motor real. Solo permite probar los casos declarativos incluidos en cada YAML.

## `src/testing/evaluateMockConditions.ts`

```ts
import type { Condition, ConditionGroup, SeverityConfig, Severity } from "../types/rule.types.js";

function getValue(input: Record<string, Record<string, unknown>>, condition: Condition): unknown {
  return input[condition.kpi]?.[condition.metric];
}

function compare(actual: unknown, operator: string, expected: unknown): boolean {
  if (operator === "exists") return actual !== undefined && actual !== null;
  if (operator === "missing") return actual === undefined || actual === null;

  if (typeof actual !== "number" || typeof expected !== "number") {
    if (operator === "==") return actual === expected;
    if (operator === "!=") return actual !== expected;
    return false;
  }

  switch (operator) {
    case ">": return actual > expected;
    case ">=": return actual >= expected;
    case "<": return actual < expected;
    case "<=": return actual <= expected;
    case "==": return actual === expected;
    case "!=": return actual !== expected;
    default: return false;
  }
}

export function evaluateConditionGroup(
  group: ConditionGroup,
  input: Record<string, Record<string, unknown>>
): boolean {
  if (group.all && !group.all.every((condition) => compare(getValue(input, condition), condition.operator, condition.value))) {
    return false;
  }

  if (group.any && !group.any.some((condition) => compare(getValue(input, condition), condition.operator, condition.value))) {
    return false;
  }

  if (group.none && group.none.some((condition) => compare(getValue(input, condition), condition.operator, condition.value))) {
    return false;
  }

  return true;
}

export function calculateMockSeverity(
  severity: SeverityConfig,
  input: Record<string, Record<string, unknown>>
): Severity {
  for (const escalation of severity.escalation ?? []) {
    if (evaluateConditionGroup(escalation.when, input)) {
      return escalation.set;
    }
  }

  return severity.default;
}
```

---

# 36. Rule test runner

## `src/testing/ruleTestRunner.ts`

```ts
import type { FaroRule } from "../types/rule.types.js";
import { evaluateConditionGroup, calculateMockSeverity } from "./evaluateMockConditions.js";

export type RuleTestResult = {
  ruleCode: string;
  testName: string;
  passed: boolean;
  expected: unknown;
  actual: unknown;
};

export function runRuleTests(rule: FaroRule): RuleTestResult[] {
  const results: RuleTestResult[] = [];

  for (const test of rule.tests) {
    const triggered = evaluateConditionGroup(rule.conditions, test.input);
    const severity = triggered ? calculateMockSeverity(rule.severity, test.input) : undefined;

    const actual = {
      triggered,
      severity,
      tension_code: triggered ? rule.tension_code : undefined
    };

    const expected = test.expect;

    const passed =
      expected.triggered === actual.triggered &&
      (expected.severity === undefined || expected.severity === actual.severity) &&
      (expected.tension_code === undefined || expected.tension_code === actual.tension_code);

    results.push({
      ruleCode: rule.rule_code,
      testName: test.name,
      passed,
      expected,
      actual
    });
  }

  return results;
}
```

---

# 37. Comando test

## `src/commands/testCommand.ts`

```ts
import { findYamlFiles } from "../utils/fileWalker.js";
import { loadYamlFile } from "../parser/loadYaml.js";
import { validateRule } from "../validators/validateRule.js";
import { runRuleTests } from "../testing/ruleTestRunner.js";
import type { FaroRule } from "../types/rule.types.js";

export async function testCommand(path: string): Promise<void> {
  const files = await findYamlFiles(path);
  let failures = 0;
  let total = 0;

  for (const file of files) {
    const rule = await loadYamlFile(file);
    const validation = validateRule(rule);

    if (!validation.valid) {
      console.log(`❌ Invalid rule, skipping tests: ${file}`);
      failures++;
      continue;
    }

    const results = runRuleTests(rule as FaroRule);

    for (const result of results) {
      total++;

      if (result.passed) {
        console.log(`✅ ${result.ruleCode} · ${result.testName}`);
      } else {
        failures++;
        console.log(`❌ ${result.ruleCode} · ${result.testName}`);
        console.log(`   Expected: ${JSON.stringify(result.expected)}`);
        console.log(`   Actual:   ${JSON.stringify(result.actual)}`);
      }
    }
  }

  console.log("");
  console.log(`Rule tests finished: ${total} tests, ${failures} failures`);

  if (failures > 0) {
    process.exitCode = 1;
  }
}
```

---

# 38. CLI principal

## `src/cli.ts`

```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);
```

---

# 39. Export principal

## `src/index.ts`

```ts
export { parseRuleFile } from "./parser/parseRuleFile.js";
export { validateRule } from "./validators/validateRule.js";
export { normalizeRule } from "./parser/normalizeRule.js";
export { runRuleTests } from "./testing/ruleTestRunner.js";

export type { FaroRule } from "./types/rule.types.js";
export type { ValidationResult } from "./types/validation.types.js";
```

---

# 40. Ejemplo de regla válida

## `rules/mvp/commercial/TNS-001_crecimiento_no_rentable.yaml`

```yaml
rule_code: RULE-TNS-001
tension_code: TNS-001
version: 1
status: active
name: Crecimiento no rentable
description: Detecta aumento de ventas acompañado de caída de margen y aumento de descuentos.

scope:
  company_types: [commercial, retail, distribution, construction_supplies]
  modules: [sales, margin, discounts]
  frequency: weekly
  evaluation_window: current_period
  comparison_window: previous_period

data_requirements:
  required_kpis: [KPI-SAL-001, KPI-SAL-002, KPI-SAL-003]
  minimum_confidence_score: 75
  missing_data_policy: do_not_trigger
  stale_data_policy: warn

conditions:
  all:
    - kpi: KPI-SAL-001
      metric: delta_pct
      operator: ">="
      value: 0.10
    - kpi: KPI-SAL-002
      metric: delta_pct
      operator: "<="
      value: -0.10
    - kpi: KPI-SAL-003
      metric: delta_pct
      operator: ">="
      value: 0.30

severity:
  default: high
  escalation:
    - when:
        all:
          - kpi: KPI-SAL-002
            metric: value
            operator: "<="
            value: 0.22
          - kpi: KPI-SAL-003
            metric: value
            operator: ">="
            value: 0.10
      set: critical

output:
  create_tension: true
  title: Crecimiento no rentable
  diagnosis_template: >
    Las ventas crecieron, pero el margen cayó y los descuentos aumentaron.
    La empresa está vendiendo más, pero capturando menos rentabilidad.
  recommended_actions: [ACT-COM-001, ACT-COM-002, ACT-COM-003]
  assign_to_role: commercial_manager
  approver_role: general_manager
  evidence_required: [EVD-007, EVD-012]
  default_sla_days: 7
  score_impact:
    base: -8
    max: -12

tests:
  - name: dispara_con_crecimiento_no_rentable
    input:
      KPI-SAL-001: {delta_pct: 0.18, confidence_score: 90}
      KPI-SAL-002: {value: 0.21, delta_pct: -0.24, confidence_score: 88}
      KPI-SAL-003: {value: 0.12, delta_pct: 0.98, confidence_score: 86}
    expect:
      triggered: true
      severity: critical
      tension_code: TNS-001

  - name: no_dispara_si_margen_no_cae
    input:
      KPI-SAL-001: {delta_pct: 0.18, confidence_score: 90}
      KPI-SAL-002: {value: 0.29, delta_pct: 0.02, confidence_score: 88}
      KPI-SAL-003: {value: 0.12, delta_pct: 0.98, confidence_score: 86}
    expect:
      triggered: false
```

---

# 41. Comandos esperados

## Validar reglas

```bash
npm run validate
```

Equivalente:

```bash
tsx src/cli.ts validate --path ./rules/mvp
```

Salida esperada:

```text
✅ 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
```

## Testear reglas

```bash
npm run test:rules
```

Salida esperada:

```text
✅ RULE-TNS-001 · dispara_con_crecimiento_no_rentable
✅ RULE-TNS-001 · no_dispara_si_margen_no_cae

Rule tests finished: 2 tests, 0 failures
```

## Importar reglas en modo dry-run

```bash
tsx src/cli.ts import --path ./rules/mvp --dry-run
```

## Importar reglas globales FARO

```bash
tsx src/cli.ts import --path ./rules/mvp
```

## Importar reglas específicas de una empresa

```bash
tsx src/cli.ts import \
  --path ./rules/mvp \
  --company-id 10000000-0000-0000-0000-000000000001
```

---

# 42. Reglas de aceptación del parser

El parser se considera aceptado si:

| Criterio                           | Estado esperado |
| ---------------------------------- | --------------- |
| Lee YAML individual                | Sí              |
| Lee carpeta completa               | Sí              |
| Valida JSON Schema                 | Sí              |
| Rechaza regla incompleta           | Sí              |
| Rechaza operador inválido          | Sí              |
| Rechaza KPI inexistente            | Sí              |
| Rechaza acción inexistente         | Sí              |
| Rechaza rol inexistente            | Sí              |
| Rechaza evidencia inexistente      | Sí              |
| Rechaza contenido inseguro         | Sí              |
| Exige tests mínimos                | Sí              |
| Convierte YAML a JSON normalizado  | Sí              |
| Inserta en `faro.rule_definitions` | Sí              |
| Soporta dry-run                    | Sí              |
| Reporta errores claros             | Sí              |
| Puede correr en CI                 | Sí              |

---

# 43. Integración con CI/CD

## GitHub Actions conceptual

```yaml
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:

```text
Ninguna regla YAML entra a main si no valida y no pasa tests.
```

---

# 44. Errores típicos y mensajes esperados

| Error              | Mensaje                                           |
| ------------------ | ------------------------------------------------- |
| Falta `rule_code`  | `SCHEMA_VALIDATION_ERROR: /rule_code is required` |
| KPI no existe      | `UNKNOWN_REQUIRED_KPI: KPI requerido no existe`   |
| Operador inválido  | `UNSUPPORTED_OPERATOR`                            |
| Acción no existe   | `UNKNOWN_ACTION_CODE`                             |
| Rol no existe      | `UNKNOWN_ASSIGN_ROLE`                             |
| Sin evidencia      | `EVIDENCE_REQUIRED_EMPTY`                         |
| Sin test positivo  | `NO_POSITIVE_TEST`                                |
| Contenido inseguro | `FORBIDDEN_RULE_CONTENT`                          |

---

# 45. Riesgos y mitigaciones

| Riesgo                                   | Severidad | Mitigación                                 |
| ---------------------------------------- | --------- | ------------------------------------------ |
| Parser demasiado permisivo               | Alta      | JSON Schema + validadores cruzados         |
| Parser demasiado rígido                  | Media     | Versionar schema                           |
| KPIs en registry desactualizados         | Media     | Leer de base `kpi_definitions`             |
| Reglas sin tests reales                  | Alta      | Bloquear import                            |
| Regla genera falso positivo              | Alta      | Test negativo obligatorio                  |
| Regla toca datos sensibles               | Alta      | Validación security                        |
| Regla específica pisa global sin control | Media     | Prioridad explícita y auditoría            |
| Importa regla a tenant equivocado        | Alta      | `company_id` explícito + RLS               |
| Cambios sin versionar                    | Alta      | No permitir overwrite de versión histórica |
| IA decide regla                          | Crítica   | Prohibido por diseño                       |

---

# 46. Mejoras recomendadas v1.1

Después del MVP, mejorar:

1. Leer KPIs desde PostgreSQL.
2. Leer acciones desde biblioteca oficial en DB.
3. Leer roles desde `faro.roles`.
4. Leer evidencias desde catálogo DB.
5. Agregar soporte de expresiones compuestas.
6. Agregar modo `explain`.
7. Agregar diff entre versiones de reglas.
8. Agregar firma/aprobación de regla.
9. Agregar UI admin de reglas.
10. Agregar simulación de regla contra dataset histórico.

---

# 47. Roadmap técnico inmediato

## Paso 1 · Parser local

```text
YAML → schema → validadores → normalización
```

## Paso 2 · Tests declarativos

```text
YAML tests → mock evaluator → pass/fail
```

## Paso 3 · Import DB

```text
YAML válido → faro.rule_definitions
```

## Paso 4 · Integración con motor

```text
rule_definitions → FARO-ENG-003 → rule_evaluations → tensions
```

## Paso 5 · CI/CD

```text
Pull request con regla nueva → validate → test → merge
```

---

# 48. Qué NO debe permitirse

El parser debe bloquear:

```yaml
raw_sql: "DROP TABLE faro.fact_sales"
```

```yaml
javascript: "eval('...')"
```

```yaml
llm_decision: true
```

```yaml
bypass_rls: true
```

```yaml
output:
  close_action_automatically: true
```

```yaml
output:
  evidence_required: []
```

Una regla FARO puede detectar, recomendar, asignar y pedir evidencia.
No puede cerrar, aprobar ni esconder trazabilidad.

---

# 49. Definición de “regla lista para producción”

Una regla está lista para producción si cumple:

| Requisito                        | Estado |
| -------------------------------- | ------ |
| YAML válido                      | Sí     |
| Schema válido                    | Sí     |
| KPIs existentes                  | Sí     |
| Acciones existentes              | Sí     |
| Roles existentes                 | Sí     |
| Evidencia existente              | Sí     |
| Tests positivos                  | Sí     |
| Tests negativos                  | Sí     |
| Seguridad validada               | Sí     |
| Versionado correcto              | Sí     |
| Aprobación funcional             | Sí     |
| Importada a DB                   | Sí     |
| Probada contra dataset demo      | Sí     |
| Monitoreada por falsos positivos | Sí     |

---

# 50. Frase ejecutiva

FARO-ENG-002 convierte las reglas FARO en un activo técnico gobernable.

```text
Antes:
las tensiones eran documentación.

Ahora:
las tensiones son configuración validada, testeable e importable.
```

El siguiente paso natural es:

## FARO-ENG-003 · Motor Evaluador MVP

Ese motor deberá leer `faro.rule_definitions`, tomar `kpi_snapshots`, evaluar condiciones, crear `rule_evaluations`, generar `tensions`, proponer `actions`, pedir `evidence` y alimentar el `FARO Score`.

Ahí FARO empieza a caminar solo.
