Files

493 lines
16 KiB
Ruby

# 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