# 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] def errores @errores end # Obtiene las advertencias acumuladas # # @return [Array] 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?('