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,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