493 lines
16 KiB
Ruby
493 lines
16 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
# tools/adn/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 tools/adn.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
|