Refactor(ADN): Migración de tools/adn a adn/tools en busca de la Armonía Integral del DIIAA
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# adn/tools/core/colores.rb — ADN Tools Colors utility
|
||||
# ====================================================================
|
||||
# Consolidación de módulos de Color (Menos es Más) para uso en CLI.
|
||||
|
||||
module Color
|
||||
RESET = "\e[0m"
|
||||
BOLD = "\e[1m"
|
||||
GREEN = "\e[32m"
|
||||
CYAN = "\e[36m"
|
||||
YELLOW = "\e[33m"
|
||||
RED = "\e[31m"
|
||||
MAGENTA = "\e[35m"
|
||||
DIM = "\e[2m"
|
||||
end
|
||||
@@ -0,0 +1,207 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'date'
|
||||
require 'time'
|
||||
require 'json'
|
||||
require_relative 'configurador'
|
||||
|
||||
module ADN
|
||||
class Conciliador
|
||||
def initialize(logger)
|
||||
@logger = logger
|
||||
@candados_path = File.join(ADN::PROJECT_ROOT, 'tools', 'ns8-candados', 'ns8-candados.rb')
|
||||
end
|
||||
|
||||
def ejecutar(silencioso: false)
|
||||
@silencioso = silencioso
|
||||
@logger.info("Iniciando saneamiento preventivo de pendientes...") unless @silencioso
|
||||
|
||||
# 1. Reconciliar backups de ayer y hoy
|
||||
conciliar_backups_recientes
|
||||
|
||||
# 2. Traspasar pendientes no resueltos de ayer a hoy
|
||||
traspasar_pendientes_ayer_a_hoy
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def conciliar_backups_recientes
|
||||
# Revisamos ayer y hoy
|
||||
[Date.today - 1, Date.today].each do |fecha_obj|
|
||||
fecha = fecha_obj.iso8601
|
||||
ruta = File.join(ADN::BITACORAS_DIR, "#{fecha}.md")
|
||||
next unless File.exist?(ruta)
|
||||
|
||||
# Si el archivo tiene relojes de arena (⏳)
|
||||
contenido = File.read(ruta)
|
||||
if contenido.include?('⏳')
|
||||
@logger.info(" Revisando tareas pendientes del #{fecha}...") unless @silencioso
|
||||
procesar_reconciliacion(fecha, ruta)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def procesar_reconciliacion(fecha, ruta)
|
||||
# Obtener tareas de Proxmox
|
||||
tasks = obtener_tareas_proxmox
|
||||
return if tasks.empty?
|
||||
|
||||
vmid_map = cargar_mapa_vmid_nodos
|
||||
contenido = File.read(ruta)
|
||||
lineas = contenido.lines
|
||||
nodo_actual = nil
|
||||
cambio = false
|
||||
|
||||
lineas.each do |linea|
|
||||
if linea.start_with?('### ') && !linea.include?('|')
|
||||
nodo_actual = linea.sub('### ', '').strip
|
||||
next
|
||||
end
|
||||
|
||||
if nodo_actual && linea.include?('|') && linea.include?('⏳')
|
||||
match_hora = linea.match(/\|\s*(\d{2}:\d{2})\s*\|/)
|
||||
next unless match_hora
|
||||
|
||||
hora_inicio_str = match_hora[1]
|
||||
vmid = vmid_map.key(nodo_actual)
|
||||
next unless vmid
|
||||
|
||||
# Buscar tarea coincidente
|
||||
hora_inicio_t = Time.parse("#{fecha} #{hora_inicio_str}") rescue nil
|
||||
tarea = tasks.select { |t|
|
||||
vmid_task = (t['vmid'] || t['id']).to_s
|
||||
t['type'] == 'vzdump' &&
|
||||
vmid_task == vmid &&
|
||||
(t['status'] == 'OK' || !t['endtime'].nil?) &&
|
||||
(hora_inicio_t ? Time.at(t['starttime']) >= (hora_inicio_t - 3600) : true)
|
||||
}.max_by { |t| t['endtime'] || 0 }
|
||||
|
||||
if tarea && tarea['endtime']
|
||||
exito = (tarea['status'] == 'OK')
|
||||
hora_fin = Time.at(tarea['endtime']).strftime('%H:%M')
|
||||
task_id = tarea['id']
|
||||
|
||||
@logger.exito(" ✨ Tarea auto-resuelta: #{nodo_actual} (#{hora_inicio_str} -> #{hora_fin})") unless @silencioso
|
||||
|
||||
# Aplicar cambio en el contenido
|
||||
estado_ico = exito ? '✅' : '⚠️'
|
||||
desc = exito ? 'Backup completado exitosamente.' : 'Fallo en la ejecución del backup.'
|
||||
desc = "[task:#{task_id[0..7]}] #{desc}"
|
||||
|
||||
linea_nueva = "| #{hora_inicio_str} | #{hora_fin} | #{desc} | R | #{estado_ico} |\n"
|
||||
|
||||
# Reemplazo cuidadoso
|
||||
regex_fila = /^\|\s*#{Regexp.escape(hora_inicio_str)}\s*\|\s*\|\s*[^|]*\|\s*[^|]*\|\s*[^|]*⏳[^|]*\|/
|
||||
|
||||
# Buscar el bloque del nodo
|
||||
regex_seccion = /(### #{Regexp.escape(nodo_actual)}.*?)(?=\n###\s|<!--|\z)/m
|
||||
contenido.sub!(regex_seccion) do |seccion|
|
||||
nueva_seccion = seccion.sub(regex_fila, linea_nueva)
|
||||
# Si ya no quedan ⏳ en esta sección, actualizamos su icono principal
|
||||
unless nueva_seccion.include?('⏳')
|
||||
nueva_seccion.gsub!(/#### [⏳⏳] - BKP-ADN/, "#### #{estado_ico} - BKP-ADN")
|
||||
end
|
||||
nueva_seccion
|
||||
end
|
||||
cambio = true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if cambio
|
||||
File.write(ruta, contenido)
|
||||
# Sincronizar con DB
|
||||
system("#{File.join(ADN::PROJECT_ROOT, 'adn/tools/run')} db migrar:md #{fecha} --sobrescribir > /dev/null 2>&1")
|
||||
end
|
||||
end
|
||||
|
||||
def traspasar_pendientes_ayer_a_hoy
|
||||
ayer = (Date.today - 1).iso8601
|
||||
hoy = Date.today.iso8601
|
||||
ruta_ayer = File.join(ADN::BITACORAS_DIR, "#{ayer}.md")
|
||||
ruta_hoy = File.join(ADN::BITACORAS_DIR, "#{hoy}.md")
|
||||
|
||||
return unless File.exist?(ruta_ayer) && File.exist?(ruta_hoy)
|
||||
|
||||
cont_ayer = File.read(ruta_ayer)
|
||||
cont_hoy = File.read(ruta_hoy)
|
||||
|
||||
# 1. Extraer sección Pendientes de ayer
|
||||
pendientes_ayer = extraer_tabla_seccion(cont_ayer, '### Pendientes')
|
||||
return if pendientes_ayer.empty?
|
||||
|
||||
# 2. Filtrar los que ya están en hoy (evitar duplicados)
|
||||
nuevos_pendientes = pendientes_ayer.reject do |p|
|
||||
cont_hoy.include?(p[:detalle])
|
||||
end
|
||||
|
||||
return if nuevos_pendientes.empty?
|
||||
|
||||
# 3. Insertar en hoy
|
||||
filas_nuevas = nuevos_pendientes.map { |p| "| #{p[:id]} | #{p[:nodo]} | #{p[:detalle]} |" }.join("\n")
|
||||
|
||||
# Regex más flexible para encontrar la tabla de pendientes en hoy
|
||||
regex_tabla = /(### Pendientes\s*\n\s*\| ID \| Nodo \| Detalle \|\s*\n\s*\| :--- \| :--- \| :--- \|)/
|
||||
|
||||
if cont_hoy.match?(regex_tabla)
|
||||
@logger.exito(" 🚀 Traspasando #{nuevos_pendientes.length} pendientes de ayer a hoy...")
|
||||
cont_hoy.sub!(regex_tabla) { "#{$1}\n#{filas_nuevas}" }
|
||||
File.write(ruta_hoy, cont_hoy)
|
||||
system("#{File.join(ADN::PROJECT_ROOT, 'adn/tools/run')} db migrar:md #{hoy} --sobrescribir > /dev/null 2>&1")
|
||||
end
|
||||
end
|
||||
|
||||
def extraer_tabla_seccion(contenido, encabezado)
|
||||
# Busca el bloque que empieza con el encabezado y captura todas las líneas que empiezan con |
|
||||
regex = /#{Regexp.escape(encabezado)}[^\n]*\n+((?:\|[^\n]*\n?)+)/m
|
||||
match = contenido.match(regex)
|
||||
return [] unless match
|
||||
|
||||
filas = match[1].strip.split("\n")
|
||||
return [] if filas.length < 3 # Necesitamos al menos Cabecera, Separador y 1 Fila
|
||||
|
||||
# Saltamos las primeras dos líneas (Cabecera y Separador)
|
||||
filas[2..-1].map do |f|
|
||||
parts = f.split('|').map(&:strip).reject(&:empty?)
|
||||
next if parts.length < 3
|
||||
{ id: parts[0], nodo: parts[1], detalle: parts[2] }
|
||||
end.compact
|
||||
end
|
||||
|
||||
def obtener_tareas_proxmox
|
||||
return @tasks if @tasks
|
||||
|
||||
system("ruby #{@candados_path} authorize > /dev/null 2>&1")
|
||||
@tasks = []
|
||||
|
||||
[ '10.0.10.201', '10.0.10.202', '10.0.10.203' ].each do |ip|
|
||||
cmd = "pvesh get /cluster/tasks --output-format json"
|
||||
output = `ruby #{@candados_path} run admindasu SSHPASS 'ssh -o StrictHostKeyChecking=no root@#{ip} "#{cmd}"' 2>/dev/null`
|
||||
|
||||
if output.include?('[')
|
||||
begin
|
||||
json_str = output[output.index('[')..output.rindex(']')]
|
||||
@tasks = JSON.parse(json_str)
|
||||
break
|
||||
rescue
|
||||
next
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
system("ruby #{@candados_path} cerrar > /dev/null 2>&1")
|
||||
@tasks
|
||||
end
|
||||
|
||||
def cargar_mapa_vmid_nodos
|
||||
mapa = {}
|
||||
Dir.glob(File.join(ADN::NODOS_DIR, "*.md")).each do |f|
|
||||
nombre = File.basename(f, ".md")
|
||||
cont = File.read(f)
|
||||
vmid = cont.match(/(?:VM ID|vmid|VMID)\*\*?:?\s*`?(\d+)`?/i)&.[](1)
|
||||
mapa[vmid.to_s] = nombre if vmid
|
||||
end
|
||||
mapa
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,50 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'yaml'
|
||||
require 'fileutils'
|
||||
|
||||
module ADN
|
||||
class Configurador
|
||||
CONFIG_PATH = File.join(__dir__, '..', 'config', 'config.yml')
|
||||
|
||||
def self.leer
|
||||
return {} unless File.exist?(CONFIG_PATH)
|
||||
YAML.load_file(CONFIG_PATH) || {}
|
||||
rescue StandardError => e
|
||||
warn "Error leyendo configuración: #{e.message}"
|
||||
{}
|
||||
end
|
||||
|
||||
def self.guardar(config)
|
||||
FileUtils.mkdir_p(File.dirname(CONFIG_PATH))
|
||||
File.write(CONFIG_PATH, config.to_yaml)
|
||||
true
|
||||
rescue StandardError => e
|
||||
warn "Error guardando configuración: #{e.message}"
|
||||
false
|
||||
end
|
||||
|
||||
def self.obtener(ruta_punto, default = nil)
|
||||
config = leer
|
||||
partes = ruta_punto.split('.')
|
||||
valor = config
|
||||
partes.each do |p|
|
||||
valor = valor[p] if valor.is_a?(Hash)
|
||||
return default if valor.nil?
|
||||
end
|
||||
valor
|
||||
end
|
||||
|
||||
def self.establecer(ruta_punto, nuevo_valor)
|
||||
config = leer
|
||||
partes = ruta_punto.split('.')
|
||||
temp = config
|
||||
partes[0...-1].each do |p|
|
||||
temp[p] ||= {}
|
||||
temp = temp[p]
|
||||
end
|
||||
temp[partes.last] = nuevo_valor
|
||||
guardar(config)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,340 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# adn/tools/eventos.rb — Bus de eventos interno para el sistema ADN
|
||||
# ==================================================================
|
||||
# Este módulo implementa un bus de eventos que permite la comunicación
|
||||
# desacoplada entre los diferentes componentes del sistema ADN.
|
||||
# Facilita la implementación del Principio de Armonía Integral al
|
||||
# permitir que cambios en un componente disparen acciones automáticas
|
||||
# en otros componentes dependientes.
|
||||
#
|
||||
# 📅 Última actualización: 2026-03-07 (Fase 2: Automatización Inteligente)
|
||||
# 📚 Referencia: docs/plan/260307-1400_Plan_Mejoras_ADN.md
|
||||
# 🔗 Integración: adn/triggers.yml (configuración de triggers)
|
||||
|
||||
require 'singleton'
|
||||
|
||||
module ADN
|
||||
class Eventos
|
||||
include Singleton
|
||||
|
||||
# Eventos predefinidos del sistema ADN
|
||||
# Estos eventos corresponden a los triggers definidos en adn/triggers.yml
|
||||
EVENTOS = {
|
||||
# Bitácoras
|
||||
bitacora_creada: :bitacora_creada,
|
||||
bitacora_modificada: :bitacora_modificada,
|
||||
bitacora_guardada: :bitacora_guardada,
|
||||
nueva_vinyeta_cronologia: :nueva_vinyeta_cronologia,
|
||||
estado_cambiado: :estado_cambiado,
|
||||
metrica_hibrida_registrada: :metrica_hibrida_registrada,
|
||||
error_formato_detectado: :error_formato_detectado,
|
||||
|
||||
# Nodos
|
||||
nodo_creado: :nodo_creado,
|
||||
nodo_modificado: :nodo_modificado,
|
||||
estado_nodo_cambiado: :estado_nodo_cambiado,
|
||||
|
||||
# Proyectos
|
||||
proyecto_creado: :proyecto_creado,
|
||||
proyecto_modificado: :proyecto_modificado,
|
||||
hito_proyecto_actualizado: :hito_proyecto_actualizado,
|
||||
estado_proyecto_cambiado: :estado_proyecto_cambiado,
|
||||
|
||||
# ADN y configuración
|
||||
hebra_adn_modificada: :hebra_adn_modificada,
|
||||
iconografia_actualizada: :iconografia_actualizada,
|
||||
directivas_ia_actualizadas: :directivas_ia_actualizadas,
|
||||
configuracion_actualizada: :configuracion_actualizada,
|
||||
|
||||
# Sistema
|
||||
sistema_iniciado: :sistema_iniciado,
|
||||
validacion_completada: :validacion_completada,
|
||||
error_sistema: :error_sistema,
|
||||
advertencia_sistema: :advertencia_sistema
|
||||
}.freeze
|
||||
|
||||
# Inicializa el bus de eventos
|
||||
def initialize
|
||||
@suscriptores = Hash.new { |hash, key| hash[key] = [] }
|
||||
@logger = ADN.logger
|
||||
@mutex = Mutex.new
|
||||
@logger.debug('Bus de eventos ADN inicializado')
|
||||
end
|
||||
|
||||
# Registra un suscriptor para un tipo de evento
|
||||
#
|
||||
# @param evento [Symbol, String] Tipo de evento al que suscribirse
|
||||
# @param bloque [Proc] Bloque a ejecutar cuando se publique el evento
|
||||
# @param identificador [String] Identificador opcional para el suscriptor
|
||||
# @return [String] Identificador del suscriptor
|
||||
def suscriptor(evento, identificador = nil, &bloque)
|
||||
evento_normalizado = normalizar_evento(evento)
|
||||
|
||||
@mutex.synchronize do
|
||||
id = identificador || "suscriptor_#{@suscriptores[evento_normalizado].length + 1}"
|
||||
@suscriptores[evento_normalizado] << { id: id, bloque: bloque }
|
||||
@logger.debug("Suscriptor registrado: #{id} para evento #{evento_normalizado}")
|
||||
id
|
||||
end
|
||||
end
|
||||
|
||||
# Publica un evento a todos los suscriptores registrados
|
||||
#
|
||||
# @param evento [Symbol, String] Tipo de evento a publicar
|
||||
# @param datos [Hash] Datos asociados al evento
|
||||
# @param opciones [Hash] Opciones de publicación
|
||||
# @option opciones [Boolean] :async Ejecutar suscriptores en hilos separados
|
||||
# @option opciones [Boolean] :silencioso No loggear la publicación
|
||||
# @return [Array<Hash>] Resultados de la ejecución de suscriptores
|
||||
def publicar(evento, datos = {}, opciones = {})
|
||||
evento_normalizado = normalizar_evento(evento)
|
||||
suscriptores = @suscriptores[evento_normalizado].dup
|
||||
|
||||
return [] if suscriptores.empty?
|
||||
|
||||
@logger.debug("Publicando evento #{evento_normalizado} a #{suscriptores.length} suscriptor(es)", datos) unless opciones[:silencioso]
|
||||
|
||||
resultados = []
|
||||
suscriptores.each do |suscriptor|
|
||||
resultado = ejecutar_suscriptor(suscriptor, evento_normalizado, datos, opciones)
|
||||
resultados << resultado if resultado
|
||||
end
|
||||
|
||||
resultados
|
||||
end
|
||||
|
||||
# Elimina un suscriptor específico
|
||||
#
|
||||
# @param evento [Symbol, String] Tipo de evento
|
||||
# @param identificador [String] Identificador del suscriptor a eliminar
|
||||
# @return [Boolean] true si se eliminó, false si no se encontró
|
||||
def eliminar_suscriptor(evento, identificador)
|
||||
evento_normalizado = normalizar_evento(evento)
|
||||
|
||||
@mutex.synchronize do
|
||||
inicial = @suscriptores[evento_normalizado].length
|
||||
@suscriptores[evento_normalizado].reject! { |s| s[:id] == identificador }
|
||||
eliminados = inicial - @suscriptores[evento_normalizado].length
|
||||
|
||||
if eliminados > 0
|
||||
@logger.debug("Suscriptor eliminado: #{identificador} del evento #{evento_normalizado}")
|
||||
true
|
||||
else
|
||||
@logger.advertencia("Suscriptor no encontrado: #{identificador} para evento #{evento_normalizado}")
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Elimina todos los suscriptores de un evento
|
||||
#
|
||||
# @param evento [Symbol, String] Tipo de evento
|
||||
# @return [Integer] Número de suscriptores eliminados
|
||||
def limpiar_suscriptores(evento = nil)
|
||||
@mutex.synchronize do
|
||||
if evento.nil?
|
||||
total = @suscriptores.values.flatten.length
|
||||
@suscriptores.clear
|
||||
@logger.debug("Todos los suscriptores eliminados (#{total} total)")
|
||||
total
|
||||
else
|
||||
evento_normalizado = normalizar_evento(evento)
|
||||
eliminados = @suscriptores[evento_normalizado].length
|
||||
@suscriptores.delete(evento_normalizado)
|
||||
@logger.debug("#{eliminados} suscriptor(es) eliminados del evento #{evento_normalizado}")
|
||||
eliminados
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Lista todos los eventos con suscriptores
|
||||
#
|
||||
# @return [Hash] Mapa de evento -> número de suscriptores
|
||||
def lista_suscriptores
|
||||
resultado = {}
|
||||
@mutex.synchronize do
|
||||
@suscriptores.each do |evento, suscriptores|
|
||||
resultado[evento] = suscriptores.length
|
||||
end
|
||||
end
|
||||
resultado
|
||||
end
|
||||
|
||||
# Verifica si hay suscriptores para un evento
|
||||
#
|
||||
# @param evento [Symbol, String] Tipo de evento
|
||||
# @return [Boolean] true si hay suscriptores
|
||||
def tiene_suscriptores?(evento)
|
||||
evento_normalizado = normalizar_evento(evento)
|
||||
!@suscriptores[evento_normalizado].empty?
|
||||
end
|
||||
|
||||
# Métodos de conveniencia para eventos comunes
|
||||
|
||||
# Publica un evento de archivo modificado
|
||||
#
|
||||
# @param ruta [String] Ruta del archivo modificado
|
||||
# @param tipo [String] Tipo de archivo (bitacora, nodo, proyecto, hebra)
|
||||
# @param accion [String] Acción (creado, modificado, eliminado)
|
||||
# @param datos_extra [Hash] Datos adicionales
|
||||
def archivo_modificado(ruta, tipo, accion, datos_extra = {})
|
||||
datos = {
|
||||
ruta: ruta,
|
||||
tipo: tipo,
|
||||
accion: accion,
|
||||
timestamp: Time.now.utc
|
||||
}.merge(datos_extra)
|
||||
|
||||
evento = case tipo
|
||||
when 'bitacora' then :bitacora_modificada
|
||||
when 'nodo' then :nodo_modificado
|
||||
when 'proyecto' then :proyecto_modificado
|
||||
when 'hebra' then :hebra_adn_modificada
|
||||
else :archivo_modificado
|
||||
end
|
||||
|
||||
publicar(evento, datos)
|
||||
end
|
||||
|
||||
# Publica un evento de estado cambiado
|
||||
#
|
||||
# @param entidad [String] Tipo de entidad (nodo, proyecto, tarea)
|
||||
# @param nombre [String] Nombre de la entidad
|
||||
# @param estado_anterior [String] Estado anterior
|
||||
# @param estado_nuevo [String] Estado nuevo
|
||||
# @param datos_extra [Hash] Datos adicionales
|
||||
def estado_cambiado(entidad, nombre, estado_anterior, estado_nuevo, datos_extra = {})
|
||||
datos = {
|
||||
entidad: entidad,
|
||||
nombre: nombre,
|
||||
estado_anterior: estado_anterior,
|
||||
estado_nuevo: estado_nuevo,
|
||||
timestamp: Time.now.utc
|
||||
}.merge(datos_extra)
|
||||
|
||||
publicar(:estado_cambiado, datos)
|
||||
end
|
||||
|
||||
# Publica un evento de error detectado
|
||||
#
|
||||
# @param tipo [String] Tipo de error (formato, validacion, sistema)
|
||||
# @param ubicacion [String] Ubicación del error (archivo, módulo)
|
||||
# @param mensaje [String] Mensaje descriptivo del error
|
||||
# @param severidad [String] Severidad (baja, media, alta, critica)
|
||||
# @param datos_extra [Hash] Datos adicionales
|
||||
def error_detectado(tipo, ubicacion, mensaje, severidad = 'media', datos_extra = {})
|
||||
datos = {
|
||||
tipo: tipo,
|
||||
ubicacion: ubicacion,
|
||||
mensaje: mensaje,
|
||||
severidad: severidad,
|
||||
timestamp: Time.now.utc
|
||||
}.merge(datos_extra)
|
||||
|
||||
publicar(:error_formato_detectado, datos)
|
||||
end
|
||||
|
||||
# Publica un evento de validación completada
|
||||
#
|
||||
# @param tipo [String] Tipo de validación (bitacoras, nodos, proyectos, completa)
|
||||
# @param resultados [Hash] Resultados de la validación
|
||||
def validacion_completada(tipo, resultados)
|
||||
datos = {
|
||||
tipo: tipo,
|
||||
resultados: resultados,
|
||||
timestamp: Time.now.utc
|
||||
}
|
||||
|
||||
publicar(:validacion_completada, datos)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Normaliza un evento a símbolo
|
||||
#
|
||||
# @param evento [Symbol, String] Evento a normalizar
|
||||
# @return [Symbol] Evento normalizado
|
||||
def normalizar_evento(evento)
|
||||
evento.to_s.to_sym
|
||||
end
|
||||
|
||||
# Ejecuta un suscriptor individual con manejo de errores
|
||||
#
|
||||
# @param suscriptor [Hash] Información del suscriptor
|
||||
# @param evento [Symbol] Evento que se está publicando
|
||||
# @param datos [Hash] Datos del evento
|
||||
# @param opciones [Hash] Opciones de publicación
|
||||
# @return [Hash] Resultado de la ejecución
|
||||
def ejecutar_suscriptor(suscriptor, evento, datos, opciones)
|
||||
id = suscriptor[:id]
|
||||
bloque = suscriptor[:bloque]
|
||||
|
||||
inicio = Time.now
|
||||
begin
|
||||
resultado = bloque.call(datos.dup.freeze)
|
||||
duracion = Time.now - inicio
|
||||
|
||||
{
|
||||
suscriptor: id,
|
||||
evento: evento,
|
||||
exito: true,
|
||||
duracion: duracion,
|
||||
resultado: resultado,
|
||||
timestamp: inicio
|
||||
}
|
||||
rescue => e
|
||||
duracion = Time.now - inicio
|
||||
@logger.error("Error en suscriptor #{id} para evento #{evento}: #{e.message}", {
|
||||
backtrace: e.backtrace.take(5),
|
||||
evento: evento,
|
||||
datos: datos
|
||||
})
|
||||
|
||||
{
|
||||
suscriptor: id,
|
||||
evento: evento,
|
||||
exito: false,
|
||||
duracion: duracion,
|
||||
error: e.message,
|
||||
backtrace: e.backtrace.take(5),
|
||||
timestamp: inicio
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Acceso global al bus de eventos
|
||||
#
|
||||
# @return [ADN::Eventos] Instancia singleton del bus de eventos
|
||||
def self.eventos
|
||||
Eventos.instance
|
||||
end
|
||||
end
|
||||
|
||||
# ─── Notas de uso ──────────────────────────────────────────────────────
|
||||
#
|
||||
# Ejemplo de suscripción:
|
||||
# ADN.eventos.suscriptor(:bitacora_creada) do |datos|
|
||||
# puts "Nueva bitácora creada: #{datos[:ruta]}"
|
||||
# end
|
||||
#
|
||||
# Ejemplo de publicación:
|
||||
# ADN.eventos.publicar(:bitacora_creada, {
|
||||
# ruta: 'bitacoras/2026-03-07.md',
|
||||
# fecha: Date.today
|
||||
# })
|
||||
#
|
||||
# Ejemplo de publicación con método de conveniencia:
|
||||
# ADN.eventos.archivo_modificado(
|
||||
# 'bitacoras/2026-03-07.md',
|
||||
# 'bitacora',
|
||||
# 'creado'
|
||||
# )
|
||||
#
|
||||
# El bus de eventos es especialmente útil para:
|
||||
# - Implementar triggers basados en eventos
|
||||
# - Desacoplar componentes del sistema
|
||||
# - Facilitar la extensibilidad mediante plugins
|
||||
# - Loggear actividades del sistema de forma centralizada
|
||||
#
|
||||
@@ -0,0 +1,228 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# adn/tools/logger.rb — Logger estructurado JSON para el sistema ADN
|
||||
# ====================================================================
|
||||
# Este módulo proporciona logging estructurado en JSON para todas las
|
||||
# herramientas del sistema ADN. Los logs se almacenan en `logs/adn/`
|
||||
# con rotación diaria automática.
|
||||
|
||||
require 'json'
|
||||
require 'fileutils'
|
||||
require 'date'
|
||||
require 'time'
|
||||
|
||||
module ADN
|
||||
class Logger
|
||||
# Niveles de log soportados
|
||||
NIVELES = %w[DEBUG INFO EXITO ADVERTENCIA ERROR].freeze
|
||||
|
||||
require_relative 'colores'
|
||||
|
||||
# Inicializa el logger
|
||||
#
|
||||
# @param opts [Hash] Opciones de configuración
|
||||
# @option opts [String] :directorio Directorio para logs (default: logs/adn/)
|
||||
# @option opts [Boolean] :terminal Mostrar logs en terminal (default: true)
|
||||
# @option opts [Boolean] :colores Usar colores en terminal (default: true)
|
||||
# @option opts [String] :archivo Nombre base del archivo de log (default: fecha actual)
|
||||
def initialize(opts = {})
|
||||
@directorio = opts[:directorio] || File.join(File.expand_path('../../..', __dir__), 'logs', 'adn')
|
||||
@mostrar_terminal = opts.fetch(:terminal, true)
|
||||
@usar_colores = opts.fetch(:colores, true)
|
||||
@archivo_base = opts[:archivo] || Date.today.iso8601
|
||||
|
||||
# Crear directorio si no existe
|
||||
FileUtils.mkdir_p(@directorio) unless Dir.exist?(@directorio)
|
||||
|
||||
# Ruta completa del archivo de log
|
||||
@ruta_log = File.join(@directorio, "#{@archivo_base}.log")
|
||||
|
||||
# Buffer para logs (útil para pruebas o modo batch)
|
||||
@buffer = []
|
||||
end
|
||||
|
||||
# Registra un mensaje de nivel DEBUG
|
||||
#
|
||||
# @param mensaje [String] Mensaje descriptivo
|
||||
# @param datos [Hash] Datos adicionales estructurados
|
||||
# @param opts [Hash] Opciones adicionales
|
||||
# @option opts [Boolean] :silencioso No mostrar en terminal
|
||||
def debug(mensaje, datos = {}, opts = {})
|
||||
escribir('DEBUG', mensaje, datos, opts)
|
||||
mostrar_terminal('🐛', mensaje, Color::DIM, opts) if debug_activado?
|
||||
end
|
||||
|
||||
# Registra un mensaje de nivel INFO
|
||||
#
|
||||
# @param mensaje [String] Mensaje descriptivo
|
||||
# @param datos [Hash] Datos adicionales estructurados
|
||||
# @param opts [Hash] Opciones adicionales
|
||||
# @option opts [Boolean] :silencioso No mostrar en terminal
|
||||
def info(mensaje, datos = {}, opts = {})
|
||||
escribir('INFO', mensaje, datos, opts)
|
||||
mostrar_terminal('ℹ', mensaje, Color::CYAN, opts)
|
||||
end
|
||||
|
||||
# Registra un mensaje de nivel ÉXITO
|
||||
#
|
||||
# @param mensaje [String] Mensaje descriptivo
|
||||
# @param datos [Hash] Datos adicionales estructurados
|
||||
# @param opts [Hash] Opciones adicionales
|
||||
# @option opts [Boolean] :silencioso No mostrar en terminal
|
||||
def exito(mensaje, datos = {}, opts = {})
|
||||
escribir('EXITO', mensaje, datos, opts)
|
||||
mostrar_terminal('✓', mensaje, Color::GREEN, opts)
|
||||
end
|
||||
|
||||
# Registra un mensaje de nivel ADVERTENCIA
|
||||
#
|
||||
# @param mensaje [String] Mensaje descriptivo
|
||||
# @param datos [Hash] Datos adicionales estructurados
|
||||
# @param opts [Hash] Opciones adicionales
|
||||
# @option opts [Boolean] :silencioso No mostrar en terminal
|
||||
def advertencia(mensaje, datos = {}, opts = {})
|
||||
escribir('ADVERTENCIA', mensaje, datos, opts)
|
||||
mostrar_terminal('⚠', mensaje, Color::YELLOW, opts)
|
||||
end
|
||||
|
||||
# Alias inglés para advertencia
|
||||
#
|
||||
# @param mensaje [String] Mensaje descriptivo
|
||||
# @param datos [Hash] Datos adicionales estructurados
|
||||
# @param opts [Hash] Opciones adicionales
|
||||
# @option opts [Boolean] :silencioso No mostrar en terminal
|
||||
def warn(mensaje, datos = {}, opts = {})
|
||||
advertencia(mensaje, datos, opts)
|
||||
end
|
||||
|
||||
# Registra un mensaje de nivel ERROR
|
||||
#
|
||||
# @param mensaje [String] Mensaje descriptivo
|
||||
# @param datos [Hash] Datos adicionales estructurados
|
||||
# @param opts [Hash] Opciones adicionales
|
||||
# @option opts [Boolean] :silencioso No mostrar en terminal
|
||||
def error(mensaje, datos = {}, opts = {})
|
||||
escribir('ERROR', mensaje, datos, opts)
|
||||
mostrar_terminal('✗', mensaje, Color::RED, opts)
|
||||
end
|
||||
|
||||
# Muestra una sugerencia de comandos ADN a utilizar
|
||||
#
|
||||
# @param comandos [String, Array<String>] Comando o comandos sugeridos
|
||||
def sugerencia(comandos)
|
||||
comandos = [comandos] unless comandos.is_a?(Array)
|
||||
puts ""
|
||||
puts " 💡 #{Color::BOLD}Sugerencia:#{Color::RESET} Podés solucionar esto usando las herramientas ADN:"
|
||||
comandos.each do |cmd|
|
||||
puts " #{Color::CYAN}#{cmd}#{Color::RESET}"
|
||||
end
|
||||
puts ""
|
||||
end
|
||||
|
||||
# Obtiene los logs almacenados en buffer
|
||||
#
|
||||
# @return [Array<Hash>] Logs en buffer
|
||||
def buffer
|
||||
@buffer.dup
|
||||
end
|
||||
|
||||
# Limpia el buffer interno
|
||||
def limpiar_buffer
|
||||
@buffer.clear
|
||||
end
|
||||
|
||||
# Cierra cualquier recurso del logger (archivos abiertos, etc.)
|
||||
def cerrar
|
||||
# Por ahora no hay recursos persistentes abiertos
|
||||
true
|
||||
end
|
||||
|
||||
# Verifica si el debug está activado (variable de entorno ADN_DEBUG)
|
||||
#
|
||||
# @return [Boolean]
|
||||
def debug_activado?
|
||||
ENV['ADN_DEBUG'] == '1' || ENV['ADN_DEBUG'] == 'true'
|
||||
end
|
||||
|
||||
# Ruta actual del archivo de log
|
||||
#
|
||||
# @return [String]
|
||||
def ruta_log
|
||||
@ruta_log
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Escribe una entrada de log en formato JSON
|
||||
#
|
||||
# @param nivel [String] Nivel del log
|
||||
# @param mensaje [String] Mensaje descriptivo
|
||||
# @param datos [Hash] Datos adicionales estructurados
|
||||
# @param opts [Hash] Opciones adicionales
|
||||
def escribir(nivel, mensaje, datos, opts)
|
||||
entrada = {
|
||||
timestamp: Time.now.utc.strftime('%Y-%m-%dT%H:%M:%S%z'),
|
||||
nivel: nivel,
|
||||
mensaje: mensaje,
|
||||
datos: datos.reject { |k, _| k == :silencioso }, # Excluir opciones internas
|
||||
herramienta: opts[:herramienta] || determinar_herramienta_llamante
|
||||
}
|
||||
|
||||
# Agregar al buffer
|
||||
@buffer << entrada.dup
|
||||
|
||||
# Escribir al archivo
|
||||
File.open(@ruta_log, 'a') do |archivo|
|
||||
archivo.puts(entrada.to_json)
|
||||
end
|
||||
end
|
||||
|
||||
# Muestra el mensaje en terminal (si está habilitado)
|
||||
#
|
||||
# @param icono [String] Icono para representar el nivel
|
||||
# @param mensaje [String] Mensaje a mostrar
|
||||
# @param color [String] Código ANSI de color
|
||||
# @param opts [Hash] Opciones adicionales
|
||||
def mostrar_terminal(icono, mensaje, color, opts)
|
||||
return unless @mostrar_terminal && !opts[:silencioso]
|
||||
|
||||
if @usar_colores
|
||||
puts "#{color}#{icono} #{mensaje}#{Color::RESET}"
|
||||
else
|
||||
puts "#{icono} #{mensaje}"
|
||||
end
|
||||
end
|
||||
|
||||
# Intenta determinar qué herramienta llamó al logger
|
||||
#
|
||||
# @return [String] Nombre de la herramienta
|
||||
def determinar_herramienta_llamante
|
||||
# Obtener el backtrace y buscar llamadas desde adn/tools/
|
||||
caller_locations.each do |loc|
|
||||
ruta = loc.absolute_path || loc.path
|
||||
if ruta.include?('adn/tools')
|
||||
# Extraer nombre del archivo sin extensión
|
||||
nombre = File.basename(ruta, '.*')
|
||||
return "adn.#{nombre}" unless nombre.empty?
|
||||
end
|
||||
end
|
||||
|
||||
'adn.desconocido'
|
||||
end
|
||||
end
|
||||
|
||||
# Logger global preconfigurado para uso rápido
|
||||
#
|
||||
# @return [ADN::Logger]
|
||||
def self.logger
|
||||
@logger ||= Logger.new
|
||||
end
|
||||
|
||||
# Configura el logger global
|
||||
#
|
||||
# @param opts [Hash] Opciones de configuración
|
||||
# @return [ADN::Logger]
|
||||
def self.configurar_logger(opts = {})
|
||||
@logger = Logger.new(opts)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,525 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# adn/tools/triggers.rb — Sistema de triggers para sincronización automática del ADN
|
||||
# =============================================================================
|
||||
# Este módulo implementa el sistema de triggers definido en adn/triggers.yml.
|
||||
# Los triggers implementan el Principio de Armonía Integral al propagar
|
||||
# automáticamente cambios entre diferentes componentes del sistema ADN.
|
||||
#
|
||||
# 📅 Última actualización: 2026-03-07 (Fase 2: Automatización Inteligente)
|
||||
# 📚 Referencia: adn/triggers.yml (configuración)
|
||||
# 📚 Plan: docs/plan/260307-1400_Plan_Mejoras_ADN.md
|
||||
# 🔗 Integración: adn/tools/eventos.rb (bus de eventos)
|
||||
|
||||
require 'yaml'
|
||||
require 'singleton'
|
||||
|
||||
|
||||
require_relative 'eventos'
|
||||
|
||||
module ADN
|
||||
class Triggers
|
||||
include Singleton
|
||||
|
||||
# Inicializa el sistema de triggers
|
||||
def initialize
|
||||
@config_path = File.join(ADN::PROJECT_ROOT, 'adn', 'triggers.yml')
|
||||
@config = nil
|
||||
@suscriptores_registrados = {}
|
||||
@logger = ADN.logger
|
||||
@mutex = Mutex.new
|
||||
@logger.debug('Sistema de triggers ADN inicializado')
|
||||
end
|
||||
|
||||
# Carga la configuración de triggers desde el archivo YAML
|
||||
#
|
||||
# @return [Hash] Configuración cargada
|
||||
# @raise [Errno::ENOENT] Si el archivo no existe
|
||||
# @raise [Psych::SyntaxError] Si el YAML es inválido
|
||||
def cargar_configuracion
|
||||
unless File.exist?(@config_path)
|
||||
@logger.advertencia("Archivo de configuración de triggers no encontrado: #{@config_path}")
|
||||
return {}
|
||||
end
|
||||
|
||||
begin
|
||||
@config = YAML.load_file(@config_path, permitted_classes: [Date, Time, Symbol])
|
||||
@logger.info("Configuración de triggers cargada desde #{@config_path}")
|
||||
@config
|
||||
rescue Psych::SyntaxError => e
|
||||
@logger.error("Error de sintaxis YAML en #{@config_path}: #{e.message}")
|
||||
raise
|
||||
rescue => e
|
||||
@logger.error("Error cargando configuración de triggers: #{e.message}")
|
||||
raise
|
||||
end
|
||||
end
|
||||
|
||||
# Obtiene la configuración actual (carga si es necesario)
|
||||
#
|
||||
# @return [Hash] Configuración actual
|
||||
def configuracion
|
||||
@config || cargar_configuracion
|
||||
end
|
||||
|
||||
# Registra todos los triggers configurados en el bus de eventos
|
||||
#
|
||||
# @return [Integer] Número de triggers registrados
|
||||
def registrar_triggers
|
||||
config = configuracion
|
||||
return 0 unless config['configuracion'] && config['configuracion']['sistema_habilitado']
|
||||
|
||||
@mutex.synchronize do
|
||||
limpiar_triggers_registrados
|
||||
|
||||
total_registrados = 0
|
||||
|
||||
# Registrar triggers de cada categoría
|
||||
%w[bitacoras nodos proyectos adn].each do |categoria|
|
||||
next unless config[categoria]
|
||||
|
||||
config[categoria].each do |trigger_def|
|
||||
next unless trigger_def['habilitado']
|
||||
|
||||
registrar_trigger_individual(trigger_def, categoria)
|
||||
total_registrados += 1
|
||||
end
|
||||
end
|
||||
|
||||
@logger.exito("#{total_registrados} trigger(s) registrado(s) en el bus de eventos")
|
||||
total_registrados
|
||||
end
|
||||
end
|
||||
|
||||
# Ejecuta todos los triggers registrados (para modo batch/cron)
|
||||
#
|
||||
# @param opciones [Hash] Opciones de ejecución
|
||||
# @option opciones [Boolean] :forzar Ejecutar incluso si sistema deshabilitado
|
||||
# @option opciones [Array<String>] :categorias Categorías específicas a ejecutar
|
||||
# @return [Hash] Resultados de la ejecución
|
||||
def ejecutar_triggers(opciones = {})
|
||||
resultados = {
|
||||
timestamp: Time.now.utc,
|
||||
total_ejecutados: 0,
|
||||
exitosos: 0,
|
||||
fallidos: 0,
|
||||
detalles: []
|
||||
}
|
||||
|
||||
config = configuracion
|
||||
|
||||
# Verificar si el sistema está habilitado
|
||||
unless config['configuracion'] && (config['configuracion']['sistema_habilitado'] || opciones[:forzar])
|
||||
@logger.advertencia('Sistema de triggers deshabilitado (usar --forzar para ejecutar de todos modos)')
|
||||
return resultados
|
||||
end
|
||||
|
||||
categorias = opciones[:categorias] || %w[bitacoras nodos proyectos adn]
|
||||
|
||||
categorias.each do |categoria|
|
||||
next unless config[categoria]
|
||||
|
||||
config[categoria].each do |trigger_def|
|
||||
next unless trigger_def['habilitado']
|
||||
|
||||
resultado = ejecutar_trigger_individual(trigger_def, categoria, opciones)
|
||||
resultados[:detalles] << resultado
|
||||
resultados[:total_ejecutados] += 1
|
||||
resultados[resultado[:exito] ? :exitosos : :fallidos] += 1
|
||||
end
|
||||
end
|
||||
|
||||
resultados
|
||||
end
|
||||
|
||||
# Prueba un trigger específico sin afectar el sistema
|
||||
#
|
||||
# @param identificador [String] Identificador del trigger a probar
|
||||
# @param datos_simulados [Hash] Datos para simular el evento
|
||||
# @return [Hash] Resultado de la prueba
|
||||
def probar_trigger(identificador, datos_simulados = {})
|
||||
trigger = buscar_trigger_por_identificador(identificador)
|
||||
|
||||
unless trigger
|
||||
@logger.error("Trigger no encontrado: #{identificador}")
|
||||
return { exito: false, error: "Trigger no encontrado: #{identificador}" }
|
||||
end
|
||||
|
||||
@logger.info("Probando trigger: #{identificador}")
|
||||
|
||||
resultado = {
|
||||
trigger: identificador,
|
||||
timestamp: Time.now.utc,
|
||||
datos_simulados: datos_simulados
|
||||
}
|
||||
|
||||
begin
|
||||
# Simular la ejecución sin efectos reales
|
||||
accion_resultado = simular_accion(trigger['accion'], trigger['categoria'], datos_simulados)
|
||||
|
||||
resultado.merge!(
|
||||
exito: true,
|
||||
accion_simulada: accion_resultado,
|
||||
mensaje: "Trigger probado exitosamente (modo simulación)"
|
||||
)
|
||||
rescue => e
|
||||
resultado.merge!(
|
||||
exito: false,
|
||||
error: e.message,
|
||||
backtrace: e.backtrace.take(5)
|
||||
)
|
||||
@logger.error("Error probando trigger #{identificador}: #{e.message}")
|
||||
end
|
||||
|
||||
resultado
|
||||
end
|
||||
|
||||
# Obtiene información sobre todos los triggers configurados
|
||||
#
|
||||
# @return [Array<Hash>] Lista de triggers con información detallada
|
||||
def lista_triggers
|
||||
config = configuracion
|
||||
triggers = []
|
||||
|
||||
%w[bitacoras nodos proyectos adn].each do |categoria|
|
||||
next unless config[categoria]
|
||||
|
||||
config[categoria].each_with_index do |trigger_def, index|
|
||||
triggers << {
|
||||
id: "#{categoria}_#{index}",
|
||||
categoria: categoria,
|
||||
evento: trigger_def['evento'],
|
||||
condicion: trigger_def['condicion'],
|
||||
accion: trigger_def['accion'],
|
||||
descripcion: trigger_def['descripcion'],
|
||||
habilitado: trigger_def['habilitado'],
|
||||
suscriptores_registrados: @suscriptores_registrados["#{categoria}_#{index}"] || 0
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
triggers
|
||||
end
|
||||
|
||||
# Limpia todos los triggers registrados del bus de eventos
|
||||
#
|
||||
# @return [Integer] Número de triggers limpiados
|
||||
def limpiar_triggers_registrados
|
||||
total = 0
|
||||
|
||||
@suscriptores_registrados.each do |trigger_id, _|
|
||||
ADN.eventos.eliminar_suscriptor(trigger_id, "trigger_#{trigger_id}")
|
||||
total += 1
|
||||
end
|
||||
|
||||
@suscriptores_registrados.clear
|
||||
@logger.debug("#{total} trigger(s) limpiados del bus de eventos")
|
||||
total
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Registra un trigger individual en el bus de eventos
|
||||
#
|
||||
# @param trigger_def [Hash] Definición del trigger
|
||||
# @param categoria [String] Categoría del trigger
|
||||
# @param index [Integer] Índice dentro de la categoría
|
||||
def registrar_trigger_individual(trigger_def, categoria, index = nil)
|
||||
evento = trigger_def['evento']
|
||||
condicion = trigger_def['condicion']
|
||||
accion = trigger_def['accion']
|
||||
descripcion = trigger_def['descripcion']
|
||||
|
||||
trigger_id = index ? "#{categoria}_#{index}" : "#{categoria}_#{Time.now.to_i}"
|
||||
|
||||
# Registrar en el bus de eventos
|
||||
suscriptor_id = ADN.eventos.suscriptor(evento, "trigger_#{trigger_id}") do |datos|
|
||||
ejecutar_accion_si_cumple_condicion(accion, condicion, datos, trigger_id)
|
||||
end
|
||||
|
||||
@suscriptores_registrados[trigger_id] = {
|
||||
evento: evento,
|
||||
condicion: condicion,
|
||||
accion: accion,
|
||||
descripcion: descripcion,
|
||||
suscriptor_id: suscriptor_id
|
||||
}
|
||||
|
||||
@logger.debug("Trigger registrado: #{trigger_id} (#{descripcion})")
|
||||
end
|
||||
|
||||
# Ejecuta una acción si se cumple la condición
|
||||
#
|
||||
# @param accion [String] Acción a ejecutar
|
||||
# @param condicion [String] Condición a evaluar
|
||||
# @param datos [Hash] Datos del evento
|
||||
# @param trigger_id [String] Identificador del trigger
|
||||
def ejecutar_accion_si_cumple_condicion(accion, condicion, datos, trigger_id)
|
||||
# Evaluar condición si existe
|
||||
if condicion && !condicion.empty?
|
||||
unless evaluar_condicion(condicion, datos)
|
||||
@logger.debug("Condición no cumplida para trigger #{trigger_id}")
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
# Ejecutar acción
|
||||
ejecutar_accion(accion, datos, trigger_id)
|
||||
end
|
||||
|
||||
# Evalúa una condición contra los datos del evento
|
||||
#
|
||||
# @param condicion [String] Expresión de condición
|
||||
# @param datos [Hash] Datos del evento
|
||||
# @return [Boolean] true si la condición se cumple
|
||||
def evaluar_condicion(condicion, datos)
|
||||
# Implementación básica - en una versión futura podría usar un parser
|
||||
# Para ahora, solo verifica condiciones simples
|
||||
|
||||
case condicion
|
||||
when /estado_anterior == '([^']+)' and estado_nuevo == '([^']+)'/
|
||||
estado_anterior_esperado = $1
|
||||
estado_nuevo_esperado = $2
|
||||
|
||||
datos[:estado_anterior] == estado_anterior_esperado &&
|
||||
datos[:estado_nuevo] == estado_nuevo_esperado
|
||||
when /estado_nuevo == '([^']+)'/
|
||||
datos[:estado_nuevo] == $1
|
||||
when /estado_tema == '([^']+)'/
|
||||
datos[:estado_tema] == $1
|
||||
else
|
||||
# Si no reconocemos la condición, asumimos que se cumple
|
||||
# (para triggers sin condición explícita)
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
# Ejecuta una acción específica
|
||||
#
|
||||
# @param accion [String] Nombre de la acción
|
||||
# @param datos [Hash] Datos del evento
|
||||
# @param trigger_id [String] Identificador del trigger
|
||||
def ejecutar_accion(accion, datos, trigger_id)
|
||||
inicio = Time.now
|
||||
|
||||
@logger.info("Ejecutando acción #{accion} (trigger: #{trigger_id})")
|
||||
|
||||
begin
|
||||
resultado = case accion
|
||||
when 'actualizar_detalle_en_proceso'
|
||||
accion_actualizar_detalle_en_proceso(datos)
|
||||
when 'actualizar_resumen_integral'
|
||||
accion_actualizar_resumen_integral(datos)
|
||||
when 'ejecutar_rollover'
|
||||
accion_ejecutar_rollover(datos)
|
||||
when 'extraer_metricas_telemetria'
|
||||
accion_extraer_metricas_telemetria(datos)
|
||||
when 'validar_formato_adn'
|
||||
accion_validar_formato_adn(datos)
|
||||
when 'actualizar_ontologia'
|
||||
accion_actualizar_ontologia(datos)
|
||||
when 'vincular_nodo_a_proyectos'
|
||||
accion_vincular_nodo_a_proyectos(datos)
|
||||
when 'actualizar_dashboard_estados'
|
||||
accion_actualizar_dashboard_estados(datos)
|
||||
when 'registrar_en_bitacora'
|
||||
accion_registrar_en_bitacora(datos)
|
||||
when 'actualizar_referencias_cruzadas'
|
||||
accion_actualizar_referencias_cruzadas(datos)
|
||||
when 'generar_reporte_cierre'
|
||||
accion_generar_reporte_cierre(datos)
|
||||
when 'verificar_impacto_bitacoras'
|
||||
accion_verificar_impacto_bitacoras(datos)
|
||||
when 'actualizar_iconos_obsoletos'
|
||||
accion_actualizar_iconos_obsoletos(datos)
|
||||
when 'notificar_y_aplicar_directivas'
|
||||
accion_notificar_y_aplicar_directivas(datos)
|
||||
else
|
||||
@logger.advertencia("Acción desconocida: #{accion}")
|
||||
{ accion: accion, estado: 'desconocida', mensaje: 'Acción no implementada' }
|
||||
end
|
||||
|
||||
duracion = Time.now - inicio
|
||||
@logger.exito("Acción #{accion} completada en #{duracion.round(3)}s")
|
||||
|
||||
resultado
|
||||
rescue => e
|
||||
duracion = Time.now - inicio
|
||||
@logger.error("Error ejecutando acción #{accion}: #{e.message}", {
|
||||
trigger_id: trigger_id,
|
||||
datos: datos,
|
||||
backtrace: e.backtrace.take(5)
|
||||
})
|
||||
|
||||
{ accion: accion, estado: 'error', error: e.message, duracion: duracion }
|
||||
end
|
||||
end
|
||||
|
||||
# Simula una acción sin efectos reales (para pruebas)
|
||||
#
|
||||
# @param accion [String] Nombre de la acción
|
||||
# @param categoria [String] Categoría del trigger
|
||||
# @param datos [Hash] Datos simulados
|
||||
# @return [Hash] Resultado de la simulación
|
||||
def simular_accion(accion, categoria, datos)
|
||||
{
|
||||
accion: accion,
|
||||
categoria: categoria,
|
||||
modo: 'simulacion',
|
||||
mensaje: "Esta acción se habría ejecutado con los datos: #{datos.keys.join(', ')}",
|
||||
timestamp: Time.now.utc
|
||||
}
|
||||
end
|
||||
|
||||
# Busca un trigger por identificador
|
||||
#
|
||||
# @param identificador [String] Identificador del trigger
|
||||
# @return [Hash, nil] Definición del trigger encontrado o nil
|
||||
def buscar_trigger_por_identificador(identificador)
|
||||
config = configuracion
|
||||
|
||||
%w[bitacoras nodos proyectos adn].each do |categoria|
|
||||
next unless config[categoria]
|
||||
|
||||
config[categoria].each_with_index do |trigger_def, index|
|
||||
trigger_id = "#{categoria}_#{index}"
|
||||
return trigger_def.merge('categoria' => categoria, 'id' => trigger_id) if trigger_id == identificador
|
||||
end
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
# Ejecuta un trigger individual
|
||||
#
|
||||
# @param trigger_def [Hash] Definición del trigger
|
||||
# @param categoria [String] Categoría del trigger
|
||||
# @param opciones [Hash] Opciones de ejecución
|
||||
# @return [Hash] Resultado de la ejecución
|
||||
def ejecutar_trigger_individual(trigger_def, categoria, opciones)
|
||||
inicio = Time.now
|
||||
trigger_id = "#{categoria}_#{Time.now.to_i}_#{rand(1000)}"
|
||||
|
||||
resultado = {
|
||||
trigger_id: trigger_id,
|
||||
evento: trigger_def['evento'],
|
||||
categoria: categoria,
|
||||
descripcion: trigger_def['descripcion'],
|
||||
inicio: inicio,
|
||||
exito: false
|
||||
}
|
||||
|
||||
begin
|
||||
# Simular datos para ejecución manual
|
||||
datos_simulados = {
|
||||
modo: 'manual',
|
||||
trigger_id: trigger_id,
|
||||
timestamp: inicio
|
||||
}
|
||||
|
||||
accion_resultado = ejecutar_accion(trigger_def['accion'], datos_simulados, trigger_id)
|
||||
|
||||
resultado.merge!(
|
||||
exito: true,
|
||||
fin: Time.now,
|
||||
duracion: Time.now - inicio,
|
||||
accion_resultado: accion_resultado
|
||||
)
|
||||
rescue => e
|
||||
resultado.merge!(
|
||||
fin: Time.now,
|
||||
duracion: Time.now - inicio,
|
||||
error: e.message,
|
||||
backtrace: e.backtrace.take(3)
|
||||
)
|
||||
end
|
||||
|
||||
resultado
|
||||
end
|
||||
|
||||
# Implementaciones de acciones específicas (stubs para Fase 2)
|
||||
|
||||
def accion_actualizar_detalle_en_proceso(datos)
|
||||
{ accion: 'actualizar_detalle_en_proceso', estado: 'stub', mensaje: 'Implementado en Fase 2' }
|
||||
end
|
||||
|
||||
def accion_actualizar_resumen_integral(datos)
|
||||
{ accion: 'actualizar_resumen_integral', estado: 'stub', mensaje: 'Implementado en Fase 2' }
|
||||
end
|
||||
|
||||
def accion_ejecutar_rollover(datos)
|
||||
{ accion: 'ejecutar_rollover', estado: 'stub', mensaje: 'Implementado en Fase 2' }
|
||||
end
|
||||
|
||||
def accion_extraer_metricas_telemetria(datos)
|
||||
{ accion: 'extraer_metricas_telemetria', estado: 'stub', mensaje: 'Implementado en Fase 2' }
|
||||
end
|
||||
|
||||
def accion_validar_formato_adn(datos)
|
||||
{ accion: 'validar_formato_adn', estado: 'stub', mensaje: 'Implementado en Fase 2' }
|
||||
end
|
||||
|
||||
def accion_actualizar_ontologia(datos)
|
||||
{ accion: 'actualizar_ontologia', estado: 'stub', mensaje: 'Implementado en Fase 2' }
|
||||
end
|
||||
|
||||
def accion_vincular_nodo_a_proyectos(datos)
|
||||
{ accion: 'vincular_nodo_a_proyectos', estado: 'stub', mensaje: 'Implementado en Fase 2' }
|
||||
end
|
||||
|
||||
def accion_actualizar_dashboard_estados(datos)
|
||||
{ accion: 'actualizar_dashboard_estados', estado: 'stub', mensaje: 'Implementado en Fase 2' }
|
||||
end
|
||||
|
||||
def accion_registrar_en_bitacora(datos)
|
||||
{ accion: 'registrar_en_bitacora', estado: 'stub', mensaje: 'Implementado en Fase 2' }
|
||||
end
|
||||
|
||||
def accion_actualizar_referencias_cruzadas(datos)
|
||||
{ accion: 'actualizar_referencias_cruzadas', estado: 'stub', mensaje: 'Implementado en Fase 2' }
|
||||
end
|
||||
|
||||
def accion_generar_reporte_cierre(datos)
|
||||
{ accion: 'generar_reporte_cierre', estado: 'stub', mensaje: 'Implementado en Fase 2' }
|
||||
end
|
||||
|
||||
def accion_verificar_impacto_bitacoras(datos)
|
||||
{ accion: 'verificar_impacto_bitacoras', estado: 'stub', mensaje: 'Implementado en Fase 2' }
|
||||
end
|
||||
|
||||
def accion_actualizar_iconos_obsoletos(datos)
|
||||
{ accion: 'actualizar_iconos_obsoletos', estado: 'stub', mensaje: 'Implementado en Fase 2' }
|
||||
end
|
||||
|
||||
def accion_notificar_y_aplicar_directivas(datos)
|
||||
{ accion: 'notificar_y_aplicar_directivas', estado: 'stub', mensaje: 'Implementado en Fase 2' }
|
||||
end
|
||||
end
|
||||
|
||||
# Acceso global al sistema de triggers
|
||||
#
|
||||
# @return [ADN::Triggers] Instancia singleton del sistema de triggers
|
||||
def self.triggers
|
||||
Triggers.instance
|
||||
end
|
||||
end
|
||||
|
||||
# ─── Notas de uso ──────────────────────────────────────────────────────
|
||||
#
|
||||
# Ejemplo de registro y uso:
|
||||
# # Cargar configuración y registrar triggers
|
||||
# ADN.triggers.cargar_configuracion
|
||||
# ADN.triggers.registrar_triggers
|
||||
#
|
||||
# Ejemplo de ejecución manual:
|
||||
# resultados = ADN.triggers.ejecutar_triggers(forzar: true)
|
||||
#
|
||||
# Ejemplo de prueba de trigger:
|
||||
# resultado = ADN.triggers.probar_trigger('bitacoras_0', { estado_tema: '⏳' })
|
||||
#
|
||||
# El sistema de triggers se integra automáticamente con el bus de eventos.
|
||||
# Cuando ocurre un evento (ej: bitácora modificada), los triggers registrados
|
||||
# que escuchan ese evento se ejecutarán automáticamente si se cumplen las condiciones.
|
||||
#
|
||||
# ⚠️ ADVERTENCIA: Este módulo está en desarrollo (Fase 2).
|
||||
# Las acciones específicas se implementarán en iteraciones posteriores.
|
||||
@@ -0,0 +1,492 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# adn/tools/validador.rb — Validador de cumplimiento del ADN
|
||||
# =============================================================
|
||||
# Este módulo valida que bitácoras, nodos, proyectos y hebras ADN
|
||||
# cumplan con las reglas definidas en las hebras del ADN.
|
||||
#
|
||||
# Validaciones implementadas:
|
||||
# • Formato de tiempo (HH:MM, sin "hs")
|
||||
# • Iconografía de estados y semántica correcta
|
||||
# • Presencia de métricas híbridas ([Físico/Remoto:])
|
||||
# • Referencias bidireccionales en rollovers
|
||||
# • Estructura de tablas en bitácoras
|
||||
# • Campos obligatorios en nodos y proyectos
|
||||
# • Consistencia entre hebras ADN
|
||||
#
|
||||
# Uso desde CLI:
|
||||
# ruby adn/tools.rb validador [--watch] [--fix]
|
||||
#
|
||||
# Uso programático:
|
||||
# validador = ADN::Validador.new
|
||||
# resultado = validador.validar_bitacoras
|
||||
# resultado = validador.validar_todo
|
||||
|
||||
require 'yaml'
|
||||
require 'json'
|
||||
require 'fileutils'
|
||||
require 'date'
|
||||
|
||||
module ADN
|
||||
class Validador
|
||||
# Inicializa el validador con configuración
|
||||
#
|
||||
# @param config_path [String] Ruta al archivo de configuración
|
||||
# @param logger [ADN::Logger] Instancia de logger (opcional)
|
||||
def initialize(config_path = nil, logger = nil)
|
||||
@config_path = config_path || File.join(__dir__, '..', 'config', 'config.yml')
|
||||
@config = cargar_configuracion
|
||||
@logger = logger || ADN.logger
|
||||
@errores = []
|
||||
@advertencias = []
|
||||
@correcciones = 0
|
||||
end
|
||||
|
||||
# Ejecuta todas las validaciones
|
||||
#
|
||||
# @return [Hash] Resultados consolidados
|
||||
def validar_todo(opciones = {})
|
||||
@logger.info('Iniciando validación completa del ADN')
|
||||
|
||||
resultados = {
|
||||
timestamp: Time.now.utc.iso8601,
|
||||
bitacoras: validar_bitacoras(opciones),
|
||||
nodos: validar_nodos(opciones),
|
||||
proyectos: validar_proyectos(opciones),
|
||||
hebras: validar_hebras_adn(opciones),
|
||||
resumen: {
|
||||
total_errores: @errores.length,
|
||||
total_advertencias: @advertencias.length,
|
||||
total_correcciones: @correcciones
|
||||
}
|
||||
}
|
||||
|
||||
resultados
|
||||
end
|
||||
|
||||
# Valida todas las bitácoras en el directorio
|
||||
#
|
||||
# @return [Hash] Resultados de validación
|
||||
def validar_bitacoras(opciones = {})
|
||||
@logger.debug("PROJECT_ROOT: #{ADN::PROJECT_ROOT}")
|
||||
@logger.debug("Ruta BITACORAS_DIR: #{ADN::BITACORAS_DIR}")
|
||||
@logger.debug("¿Directorio existe?: #{Dir.exist?(ADN::BITACORAS_DIR)}")
|
||||
@logger.info("Validando bitácoras en #{ADN::BITACORAS_DIR}")
|
||||
|
||||
resultados = {
|
||||
total: 0,
|
||||
validas: 0,
|
||||
con_errores: 0,
|
||||
con_advertencias: 0,
|
||||
detalles: []
|
||||
}
|
||||
|
||||
archivos = Dir.glob(File.join(ADN::BITACORAS_DIR, '*.md'))
|
||||
resultados[:total] = archivos.length
|
||||
|
||||
archivos.each do |archivo|
|
||||
nombre = File.basename(archivo)
|
||||
resultado_bitacora = validar_bitacora_individual(archivo, opciones)
|
||||
|
||||
resultados[:detalles] << resultado_bitacora
|
||||
|
||||
if resultado_bitacora[:errores].empty? && resultado_bitacora[:advertencias].empty?
|
||||
resultados[:validas] += 1
|
||||
elsif !resultado_bitacora[:errores].empty?
|
||||
resultados[:con_errores] += 1
|
||||
else
|
||||
resultados[:con_advertencias] += 1
|
||||
end
|
||||
end
|
||||
|
||||
resultados
|
||||
end
|
||||
|
||||
# Valida una bitácora individual
|
||||
#
|
||||
# @param ruta [String] Ruta al archivo de bitácora
|
||||
# @return [Hash] Resultados de validación
|
||||
def validar_bitacora_individual(ruta, opciones = {})
|
||||
nombre = File.basename(ruta)
|
||||
contenido = File.read(ruta, encoding: 'UTF-8')
|
||||
|
||||
resultado = {
|
||||
archivo: nombre,
|
||||
errores: [],
|
||||
advertencias: [],
|
||||
correcciones: []
|
||||
}
|
||||
|
||||
# Validar formato de nombre (YYYY-MM-DD.md)
|
||||
unless nombre.match?(/^\d{4}-\d{2}-\d{2}\.md$/)
|
||||
resultado[:errores] << "Formato de nombre incorrecto (debe ser YYYY-MM-DD.md)"
|
||||
end
|
||||
|
||||
# Validar formato de tiempo (HH:MM, sin "hs")
|
||||
if @config['validador']['validar_formato_tiempo']
|
||||
validar_formato_tiempo(contenido, resultado)
|
||||
end
|
||||
|
||||
# Validar iconografía
|
||||
if @config['validador']['validar_iconografia']
|
||||
validar_iconografia(contenido, resultado)
|
||||
end
|
||||
|
||||
# Validar métricas híbridas
|
||||
if @config['validador']['validar_metricas_hibridas']
|
||||
validar_metricas_hibridas(contenido, resultado)
|
||||
end
|
||||
|
||||
# Validar referencias de rollover
|
||||
if @config['validador']['validar_referencias_rollover']
|
||||
validar_referencias_rollover(contenido, nombre, resultado)
|
||||
end
|
||||
|
||||
# Validar estructura básica de bitácora
|
||||
validar_estructura_bitacora(contenido, resultado)
|
||||
|
||||
# Intentar correcciones si está habilitado
|
||||
if opciones[:fix] && @config['validador']['strict_mode']
|
||||
intentar_correcciones(ruta, contenido, resultado)
|
||||
end
|
||||
|
||||
resultado
|
||||
end
|
||||
|
||||
# Valida todas las fichas de nodo
|
||||
#
|
||||
# @return [Hash] Resultados de validación
|
||||
def validar_nodos(opciones = {})
|
||||
@logger.info("Validando nodos en #{ADN::NODOS_DIR}")
|
||||
|
||||
resultados = {
|
||||
total: 0,
|
||||
validos: 0,
|
||||
con_errores: 0,
|
||||
con_advertencias: 0,
|
||||
detalles: []
|
||||
}
|
||||
|
||||
archivos = Dir.glob(File.join(ADN::NODOS_DIR, '*.md'))
|
||||
resultados[:total] = archivos.length
|
||||
|
||||
archivos.each do |archivo|
|
||||
nombre = File.basename(archivo, '.md')
|
||||
contenido = File.read(archivo, encoding: 'UTF-8')
|
||||
|
||||
resultado_nodo = {
|
||||
nodo: nombre,
|
||||
errores: [],
|
||||
advertencias: []
|
||||
}
|
||||
|
||||
# Validar campos obligatorios
|
||||
campos_obligatorios = ['Hostname', 'IP LAN', 'Sistema Operativo', 'Rol']
|
||||
campos_obligatorios.each do |campo|
|
||||
unless contenido.include?("**#{campo}**")
|
||||
resultado_nodo[:errores] << "Falta campo obligatorio: #{campo}"
|
||||
end
|
||||
end
|
||||
|
||||
# Validar formato de nombre (minúsculas)
|
||||
unless nombre == nombre.downcase
|
||||
resultado_nodo[:advertencias] << "Nombre debería estar en minúsculas (gobernanza)"
|
||||
end
|
||||
|
||||
# Validar que tenga sección de especificaciones de hardware
|
||||
unless contenido.include?('## Especificaciones de Hardware')
|
||||
resultado_nodo[:advertencias] << "Falta sección 'Especificaciones de Hardware'"
|
||||
end
|
||||
|
||||
resultados[:detalles] << resultado_nodo
|
||||
|
||||
if resultado_nodo[:errores].empty? && resultado_nodo[:advertencias].empty?
|
||||
resultados[:validos] += 1
|
||||
elsif !resultado_nodo[:errores].empty?
|
||||
resultados[:con_errores] += 1
|
||||
else
|
||||
resultados[:con_advertencias] += 1
|
||||
end
|
||||
end
|
||||
|
||||
resultados
|
||||
end
|
||||
|
||||
# Valida todos los manifiestos de proyecto
|
||||
#
|
||||
# @return [Hash] Resultados de validación
|
||||
def validar_proyectos(opciones = {})
|
||||
@logger.info("Validando proyectos en #{ADN::PROYECTOS_DIR}")
|
||||
|
||||
resultados = {
|
||||
total: 0,
|
||||
validos: 0,
|
||||
con_errores: 0,
|
||||
con_advertencias: 0,
|
||||
detalles: []
|
||||
}
|
||||
|
||||
archivos = Dir.glob(File.join(ADN::PROYECTOS_DIR, '*.md'))
|
||||
resultados[:total] = archivos.length
|
||||
|
||||
archivos.each do |archivo|
|
||||
nombre = File.basename(archivo)
|
||||
contenido = File.read(archivo, encoding: 'UTF-8')
|
||||
|
||||
resultado_proyecto = {
|
||||
proyecto: nombre,
|
||||
errores: [],
|
||||
advertencias: []
|
||||
}
|
||||
|
||||
# Validar formato de nombre (PXXXX_nombre.md)
|
||||
unless nombre.match?(/^P\d+_[a-z0-9_]+\.md$/)
|
||||
resultado_proyecto[:advertencias] << "Formato de nombre podría no seguir convención (PXXX_nombre.md)"
|
||||
end
|
||||
|
||||
# Validar campos obligatorios
|
||||
secciones_obligatorias = ['Objetivo', 'Stakeholders', 'Topología y Contexto de Red',
|
||||
'Nodos Involucrados', 'Herramientas', 'Hitos Clave']
|
||||
secciones_obligatorias.each do |seccion|
|
||||
unless contenido.include?("## #{seccion}")
|
||||
resultado_proyecto[:advertencias] << "Falta sección: #{seccion}"
|
||||
end
|
||||
end
|
||||
|
||||
# Validar que tenga código de proyecto en título
|
||||
unless contenido.match?(/^# (P\d+) - /)
|
||||
resultado_proyecto[:errores] << "Falta código de proyecto en título (ej: # P2601 - Nombre)"
|
||||
end
|
||||
|
||||
resultados[:detalles] << resultado_proyecto
|
||||
|
||||
if resultado_proyecto[:errores].empty? && resultado_proyecto[:advertencias].empty?
|
||||
resultados[:validos] += 1
|
||||
elsif !resultado_proyecto[:errores].empty?
|
||||
resultados[:con_errores] += 1
|
||||
else
|
||||
resultados[:con_advertencias] += 1
|
||||
end
|
||||
end
|
||||
|
||||
resultados
|
||||
end
|
||||
|
||||
# Valida que todas las hebras ADN existan y tengan contenido
|
||||
#
|
||||
# @return [Hash] Resultados de validación
|
||||
def validar_hebras_adn(opciones = {})
|
||||
@logger.info("Validando hebras ADN en #{ADN::ADN_DIR}")
|
||||
|
||||
resultados = {
|
||||
total: 0,
|
||||
validas: 0,
|
||||
con_errores: 0,
|
||||
con_advertencias: 0,
|
||||
detalles: []
|
||||
}
|
||||
|
||||
# Hebras esperadas según 00_indice.md
|
||||
hebras_esperadas = (0..7).map { |i| "%02d" % i }.map { |n| "#{n}_" }
|
||||
# Nombres reales de archivos
|
||||
hebras_esperadas_nombres = [
|
||||
'00_indice.md', '01_ontologia.md', '02_bitacora.md', '03_seguridad.md',
|
||||
'04_iconografia.md', '05_ia.md', '06_gobernanza.md', '07_proyectos.md'
|
||||
]
|
||||
|
||||
resultados[:total] = hebras_esperadas_nombres.length
|
||||
|
||||
hebras_esperadas_nombres.each do |nombre_hebra|
|
||||
ruta = File.join(ADN::ADN_DIR, nombre_hebra)
|
||||
|
||||
resultado_hebra = {
|
||||
hebra: nombre_hebra,
|
||||
errores: [],
|
||||
advertencias: []
|
||||
}
|
||||
|
||||
if File.exist?(ruta)
|
||||
contenido = File.read(ruta, encoding: 'UTF-8')
|
||||
|
||||
# Validar que no esté vacía
|
||||
if contenido.strip.empty?
|
||||
resultado_hebra[:errores] << "Hebra vacía"
|
||||
end
|
||||
|
||||
# Validar que tenga título principal
|
||||
unless contenido.start_with?('# ')
|
||||
resultado_hebra[:advertencias] << "Falta título principal (# )"
|
||||
end
|
||||
|
||||
resultados[:validas] += 1 if resultado_hebra[:errores].empty?
|
||||
else
|
||||
resultado_hebra[:errores] << "Hebra no encontrada"
|
||||
resultados[:con_errores] += 1
|
||||
end
|
||||
|
||||
resultados[:detalles] << resultado_hebra
|
||||
end
|
||||
|
||||
resultados
|
||||
end
|
||||
|
||||
# Obtiene los errores acumulados
|
||||
#
|
||||
# @return [Array<String>]
|
||||
def errores
|
||||
@errores
|
||||
end
|
||||
|
||||
# Obtiene las advertencias acumuladas
|
||||
#
|
||||
# @return [Array<String>]
|
||||
def advertencias
|
||||
@advertencias
|
||||
end
|
||||
|
||||
# Obtiene el número de correcciones aplicadas
|
||||
#
|
||||
# @return [Integer]
|
||||
def correcciones
|
||||
@correcciones
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Carga la configuración desde el archivo YAML
|
||||
#
|
||||
# @return [Hash] Configuración
|
||||
def cargar_configuracion
|
||||
return {} unless File.exist?(@config_path)
|
||||
|
||||
begin
|
||||
YAML.load_file(@config_path)
|
||||
rescue => e
|
||||
@logger.error("Error cargando configuración: #{e.message}")
|
||||
{}
|
||||
end
|
||||
end
|
||||
|
||||
# Valida formato de tiempo (HH:MM, sin "hs")
|
||||
def validar_formato_tiempo(contenido, resultado)
|
||||
# Buscar patrones como "10:30 hs" o "10:30hs"
|
||||
patrones_incorrectos = contenido.scan(/\d{1,2}:\d{2}\s*hs/)
|
||||
|
||||
patrones_incorrectos.each do |patron|
|
||||
resultado[:advertencias] << "Formato de tiempo incorrecto: '#{patron}' (debe ser HH:MM sin 'hs')"
|
||||
end
|
||||
|
||||
# Validar que los tiempos en tablas sigan formato HH:MM
|
||||
# Busca patrones en tablas: | HH:MM | HH:MM | ... |
|
||||
lineas_tabla = contenido.scan(/\|\s*(\d{1,2}:\d{2})\s*\|/)
|
||||
lineas_tabla.flatten.each do |hora|
|
||||
unless hora.match?(/^\d{1,2}:\d{2}$/)
|
||||
resultado[:advertencias] << "Formato de hora en tabla incorrecto: '#{hora}'"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Valida iconografía según hebra 04_iconografia.md
|
||||
def validar_iconografia(contenido, resultado)
|
||||
# Iconos de estado permitidos (de 04_iconografia.md)
|
||||
iconos_estado_permitidos = ['✅', '⏳', '⚠️', '❌', '📍', '➡️']
|
||||
|
||||
# Buscar iconos de estado en títulos (#### [Icono] - ...)
|
||||
iconos_encontrados = contenido.scan(/####\s*([✅⏳⚠️❌📍➡️])\s*-/)
|
||||
|
||||
iconos_encontrados.flatten.each do |icono|
|
||||
unless iconos_estado_permitidos.include?(icono)
|
||||
resultado[:advertencias] << "Icono de estado no reconocido: '#{icono}'"
|
||||
end
|
||||
end
|
||||
|
||||
# Verificar que haya al menos un icono de estado si hay actividades
|
||||
if contenido.include?('#### ') && iconos_encontrados.empty?
|
||||
resultado[:advertencias] << "Actividades detalladas sin iconos de estado"
|
||||
end
|
||||
end
|
||||
|
||||
# Valida presencia de métricas híbridas
|
||||
def validar_metricas_hibridas(contenido, resultado)
|
||||
unless contenido.include?('[Físico/Remoto:')
|
||||
resultado[:advertencias] << "No se encontraron métricas híbridas ([Físico/Remoto:])"
|
||||
end
|
||||
end
|
||||
|
||||
# Valida referencias bidireccionales en rollovers
|
||||
def validar_referencias_rollover(contenido, nombre_bitacora, resultado)
|
||||
# Buscar referencias a días anteriores/siguientes
|
||||
referencias = contenido.scan(/➡️\s*\[(?:Viene del|Continúa el)\s+(\d{2}\/\d{2})\]/)
|
||||
|
||||
fecha_bitacora = nombre_bitacora.gsub('.md', '')
|
||||
fecha_obj = Date.parse(fecha_bitacora) rescue nil
|
||||
|
||||
return unless fecha_obj
|
||||
|
||||
# Verificar que las referencias apunten a bitácoras existentes
|
||||
referencias.each do |ref|
|
||||
fecha_ref_str = ref[0] # Formato "DD/MM"
|
||||
dia, mes = fecha_ref_str.split('/')
|
||||
# Asumimos mismo año por simplicidad
|
||||
fecha_ref = Date.new(fecha_obj.year, mes.to_i, dia.to_i) rescue nil
|
||||
|
||||
if fecha_ref
|
||||
nombre_ref = "#{fecha_ref.year}-%02d-%02d.md" % [fecha_ref.month, fecha_ref.day]
|
||||
ruta_ref = File.join(ADN::BITACORAS_DIR, nombre_ref)
|
||||
|
||||
unless File.exist?(ruta_ref)
|
||||
resultado[:advertencias] << "Referencia a bitácora inexistente: #{nombre_ref}"
|
||||
end
|
||||
else
|
||||
resultado[:advertencias] << "Formato de fecha en referencia inválido: #{fecha_ref_str}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Valida estructura básica de bitácora
|
||||
def validar_estructura_bitacora(contenido, resultado)
|
||||
# Debe tener sección de Control de Gestión
|
||||
unless contenido.include?('## 📋 Control de Gestión')
|
||||
resultado[:advertencias] << "Falta sección '## 📋 Control de Gestión'"
|
||||
end
|
||||
|
||||
# Debe tener tablas de Pendientes, En Proceso y Resumen
|
||||
unless contenido.include?('### Pendientes') || contenido.include?('### En Proceso') || contenido.include?('### Resumen de Actividades')
|
||||
resultado[:advertencias] << "Faltan una o más tablas de control de gestión"
|
||||
end
|
||||
|
||||
# Debe tener sección de Actividades Detalladas
|
||||
unless contenido.include?('## 📝 Actividades Detalladas')
|
||||
resultado[:advertencias] << "Falta sección '## 📝 Actividades Detalladas'"
|
||||
end
|
||||
|
||||
# Debe tener referencia a premisas de trabajo IA
|
||||
unless contenido.include?('<!-- 🤖 PREMISAS DE TRABAJO (IA)')
|
||||
resultado[:advertencias] << "Falta referencia a premisas de trabajo IA"
|
||||
end
|
||||
end
|
||||
|
||||
# Intenta corregir errores automáticamente (modo --fix)
|
||||
def intentar_correcciones(ruta, contenido, resultado)
|
||||
cambios = false
|
||||
nuevo_contenido = contenido.dup
|
||||
|
||||
# Corregir "hs" en formatos de tiempo
|
||||
if @config['validador']['validar_formato_tiempo']
|
||||
nuevo_contenido.gsub!(/(\d{1,2}:\d{2})\s*hs/, '\1')
|
||||
end
|
||||
|
||||
# Aplicar correcciones si hubo cambios
|
||||
if nuevo_contenido != contenido
|
||||
File.write(ruta, nuevo_contenido)
|
||||
cambios = true
|
||||
@correcciones += 1
|
||||
end
|
||||
|
||||
if cambios
|
||||
resultado[:correcciones] << "Se aplicaron correcciones automáticas"
|
||||
@logger.info("Correcciones aplicadas a #{File.basename(ruta)}")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user