Reestructuración modular integral de servicios, reorganización de gestión en docs/ y limpieza profunda del repositorio

This commit is contained in:
Ricardo Monla
2026-03-06 18:03:07 -03:00
parent 15725e0b3c
commit ac9bdf516b
286 changed files with 36187 additions and 112775 deletions
+3
View File
@@ -0,0 +1,3 @@
---
BUNDLE_PATH: "vendor/bundle"
BUNDLE_WITHOUT: "development:test:future"
+76
View File
@@ -0,0 +1,76 @@
source "https://rubygems.org"
# Versión de Ruby compatible con Debian 12 (bookworm)
ruby "~> 3.0"
# ─── Gemas principales (runtime) ──────────────────────────────────────
# Framework web para dashboard (Fase 3)
gem "sinatra", "~> 3.0"
# Servidor web para Sinatra (requerido en Ruby 3+)
gem "webrick", "~> 1.8"
# Monitoreo de cambios en sistema de archivos (validador --watch)
gem "listen", "~> 3.8"
# Interacción programática con Git (hooks pre-commit, análisis de commits)
# gem "rugged", "~> 1.6" # MOVIDO A GRUPO FUTURE (requiere CMake)
# Procesamiento de JSON (aunque está en stdlib, esta gema ofrece mejor rendimiento)
gem "json", "~> 2.6"
# Notificaciones desktop (validador --watch)
gem "libnotify", "~> 0.9", :platforms => :ruby
# Conexión a PostgreSQL para dtic-BITACORAs
gem "pg", "~> 1.5"
# ─── Gemas de desarrollo y pruebas ────────────────────────────────────
group :development, :test do
# Framework de pruebas
gem "rspec", "~> 3.12"
# Console interactiva para debugging
gem "pry", "~> 0.14"
# Tareas de construcción
gem "rake", "~> 13.0"
# Cobertura de código
gem "simplecov", "~> 0.22"
# Mocking y stubbing
gem "webmock", "~> 3.19"
end
# ─── Gemas opcionales (futuras fases) ────────────────────────────────
group :future do
# API REST cliente (para integración con dashboard principal)
gem "faraday", "~> 2.7"
# Serialización YAML avanzada
gem "safe_yaml", "~> 1.0"
# Envío de emails (notificaciones por email)
gem "mail", "~> 2.8"
# Cliente SMTP
gem "net-smtp", "~> 0.3"
# Interacción programática con Git (hooks pre-commit, análisis de commits)
gem "rugged", "~> 1.6"
end
# ─── Notas de instalación ─────────────────────────────────────────────
# Para instalar solo las gemas runtime (sin desarrollo):
# bundle install --without development test future
#
# Para instalar todas las gemas:
# bundle install
#
# Dependencias del sistema (Debian/Ubuntu):
# sudo apt install libgit2-dev cmake pkg-config libssl-dev
# (necesario para la gema 'rugged')
#
# Para actualizar gemas:
# bundle update
+139
View File
@@ -0,0 +1,139 @@
GEM
remote: https://rubygems.org/
specs:
addressable (2.8.9)
public_suffix (>= 2.0.2, < 8.0)
base64 (0.3.0)
bigdecimal (4.0.1)
coderay (1.1.3)
crack (1.0.1)
bigdecimal
rexml
date (3.5.1)
diff-lcs (1.6.2)
docile (1.4.1)
faraday (2.14.1)
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday-net_http (3.4.2)
net-http (~> 0.5)
ffi (1.17.3)
ffi (1.17.3-arm64-darwin)
ffi (1.17.3-x86_64-darwin)
hashdiff (1.2.1)
io-console (0.8.2)
json (2.19.0)
libnotify (0.9.4)
ffi (>= 1.0.11)
listen (3.10.0)
logger
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
logger (1.7.0)
mail (2.9.0)
logger
mini_mime (>= 0.1.1)
net-imap
net-pop
net-smtp
method_source (1.1.0)
mini_mime (1.1.5)
mustermann (3.0.4)
ruby2_keywords (~> 0.0.1)
net-http (0.9.1)
uri (>= 0.11.1)
net-imap (0.5.13)
date
net-protocol
net-pop (0.1.2)
net-protocol
net-protocol (0.2.2)
timeout
net-smtp (0.5.1)
net-protocol
pg (1.6.3)
pg (1.6.3-arm64-darwin)
pg (1.6.3-x86_64-darwin)
pg (1.6.3-x86_64-linux)
pry (0.16.0)
coderay (~> 1.1)
method_source (~> 1.0)
reline (>= 0.6.0)
public_suffix (6.0.2)
rack (2.2.22)
rack-protection (3.2.0)
base64 (>= 0.1.0)
rack (~> 2.2, >= 2.2.4)
rake (13.3.1)
rb-fsevent (0.11.2)
rb-inotify (0.11.1)
ffi (~> 1.0)
reline (0.6.3)
io-console (~> 0.5)
rexml (3.4.4)
rspec (3.13.2)
rspec-core (~> 3.13.0)
rspec-expectations (~> 3.13.0)
rspec-mocks (~> 3.13.0)
rspec-core (3.13.6)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.5)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-mocks (3.13.8)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-support (3.13.7)
ruby2_keywords (0.0.5)
rugged (1.9.0)
safe_yaml (1.0.5)
simplecov (0.22.0)
docile (~> 1.1)
simplecov-html (~> 0.11)
simplecov_json_formatter (~> 0.1)
simplecov-html (0.13.2)
simplecov_json_formatter (0.1.4)
sinatra (3.2.0)
mustermann (~> 3.0)
rack (~> 2.2, >= 2.2.4)
rack-protection (= 3.2.0)
tilt (~> 2.0)
tilt (2.7.0)
timeout (0.6.0)
uri (1.1.1)
webmock (3.26.1)
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
webrick (1.9.2)
PLATFORMS
arm64-darwin
ruby
x86_64-darwin
x86_64-linux
DEPENDENCIES
faraday (~> 2.7)
json (~> 2.6)
libnotify (~> 0.9)
listen (~> 3.8)
mail (~> 2.8)
net-smtp (~> 0.3)
pg (~> 1.5)
pry (~> 0.14)
rake (~> 13.0)
rspec (~> 3.12)
rugged (~> 1.6)
safe_yaml (~> 1.0)
simplecov (~> 0.22)
sinatra (~> 3.0)
webmock (~> 3.19)
webrick (~> 1.8)
RUBY VERSION
ruby 3.1.2p20
BUNDLED WITH
2.6.9
+517
View File
@@ -0,0 +1,517 @@
# frozen_string_literal: true
# tools/adn/clients/dtic_bitacoras_api.rb — Cliente Ruby para API de dtic-BITACORAs
# ===============================================================================
# Este cliente permite interactuar con la API REST de dtic-BITACORAs desde Ruby.
# Proporciona métodos para operaciones CRUD en nodos, bitácoras, entradas y proyectos.
#
# Configuración:
# export DTIC_API_URL=http://localhost:3002
# o configurar en tools/adn/config.yml
#
# Uso básico:
# require_relative 'clients/dtic_bitacoras_api'
# client = DticBitacorasApi::Client.new
# bitacoras = client.listar_bitacoras
# client.crear_entrada(...)
#
# Sincronización:
# client.sincronizar_md('bitacoras/2026-03-05.md')
require 'net/http'
require 'json'
require 'uri'
require 'date'
require 'yaml'
module DticBitacorasApi
# Excepciones personalizadas
class ApiError < StandardError
attr_reader :status_code, :response_body
def initialize(message, status_code = nil, response_body = nil)
super(message)
@status_code = status_code
@response_body = response_body
end
def to_s
msg = super
msg += " (Status: #{@status_code})" if @status_code
msg += "\nResponse: #{@response_body}" if @response_body
msg
end
end
class ConfigError < StandardError; end
# Cliente principal
class Client
# Inicializa el cliente con configuración
#
# @param options [Hash] Opciones de configuración
# @option options [String] :base_url URL base de la API (ej: http://localhost:3002)
# @option options [String] :config_path Ruta al archivo de configuración YAML
# @option options [Logger] :logger Instancia de logger (opcional)
# @option options [Integer] :timeout_seconds Timeout para requests HTTP (default: 30)
def initialize(options = {})
@config_path = options[:config_path] || File.join(__dir__, '..', 'config.yml')
@base_url = options[:base_url] || extraer_url_desde_config
@logger = options[:logger] || ADN.logger rescue nil
@timeout_seconds = options[:timeout_seconds] || 30
# Validar URL base
@base_url = @base_url.chomp('/')
unless @base_url.start_with?('http://', 'https://')
raise ConfigError, "URL base debe comenzar con http:// o https://: #{@base_url}"
end
@logger&.info("Cliente dtic-BITACORAs inicializado para #{@base_url}")
end
# --- MÉTODOS PARA NODOS ---
# Lista todos los nodos activos
#
# @return [Array<Hash>] Lista de nodos
def listar_nodos
get('/api/nodos')
end
# Obtiene un nodo por nombre
#
# @param nombre [String] Nombre del nodo
# @return [Hash] Datos del nodo
def obtener_nodo_por_nombre(nombre)
nodos = listar_nodos
nodos.find { |n| n['nombre'] == nombre }
end
# Crea un nuevo nodo
#
# @param nombre [String] Nombre del nodo
# @param ip [String] Dirección IP
# @param tipo [String] Tipo de nodo (servidor, vm, pc, hipervisor)
# @param descripcion [String] Descripción opcional
# @return [Hash] Nodo creado
def crear_nodo(nombre, ip: nil, tipo: 'servidor', descripcion: '')
post('/api/nodos', {
nombre: nombre,
ip: ip,
tipo: tipo,
descripcion: descripcion
})
end
# --- MÉTODOS PARA BITÁCORAS ---
# Lista las últimas bitácoras (por defecto 30 días)
#
# @param limit [Integer] Límite de resultados
# @return [Array<Hash>] Lista de bitácoras
def listar_bitacoras(limit: 30)
get("/api/bitacoras?limit=#{limit}")
end
# Obtiene o crea una bitácora para una fecha específica
#
# @param fecha [String, Date] Fecha en formato YYYY-MM-DD
# @return [Hash] Bitácora
def obtener_bitacora(fecha)
fecha_str = fecha.respond_to?(:strftime) ? fecha.strftime('%Y-%m-%d') : fecha.to_s
get("/api/bitacoras?fecha=#{fecha_str}")
end
# Obtiene bitácora completa con entradas agrupadas por nodo
#
# @param fecha [String, Date] Fecha en formato YYYY-MM-DD
# @return [Hash] Bitácora completa con nodos y entradas
def obtener_bitacora_completa(fecha)
fecha_str = fecha.respond_to?(:strftime) ? fecha.strftime('%Y-%m-%d') : fecha.to_s
get("/api/bitacoras/#{fecha_str}/completa")
end
# Exporta bitácora a formato Markdown
#
# @param fecha [String, Date] Fecha en formato YYYY-MM-DD
# @return [String] Contenido Markdown
def exportar_bitacora_md(fecha)
fecha_str = fecha.respond_to?(:strftime) ? fecha.strftime('%Y-%m-%d') : fecha.to_s
get_raw("/api/bitacoras/#{fecha_str}/export")
end
# --- MÉTODOS PARA ENTRADAS ---
# Lista entradas con filtros opcionales
#
# @param bitacora_id [Integer] ID de bitácora para filtrar
# @param nodo_id [Integer] ID de nodo para filtrar
# @return [Array<Hash>] Lista de entradas
def listar_entradas(bitacora_id: nil, nodo_id: nil)
params = []
params << "bitacora_id=#{bitacora_id}" if bitacora_id
params << "nodo_id=#{nodo_id}" if nodo_id
query = params.empty? ? '' : "?#{params.join('&')}"
get("/api/entradas#{query}")
end
# Crea una nueva entrada
#
# @param inicio [String] Hora de inicio (HH:MM)
# @param fin [String, nil] Hora de fin (HH:MM) o nil si en curso
# @param descripcion [String] Descripción de la actividad
# @param bitacora_id [Integer] ID de la bitácora
# @param nodo_id [Integer] ID del nodo
# @param estado [String] Estado (✅, ⏳, ⚠️, ❌)
# @param modo [String] Modo (P: Presencial, R: Remoto)
# @param es_ia [Boolean] Si la acción fue realizada por IA
# @return [Hash] Entrada creada
def crear_entrada(inicio:, fin: nil, descripcion:, bitacora_id:, nodo_id:,
estado: '⏳', modo: 'P', es_ia: false)
post('/api/entradas', {
inicio: inicio,
fin: fin,
descripcion: descripcion,
estado: estado,
modo: modo,
es_ia: es_ia,
bitacora_id: bitacora_id,
nodo_id: nodo_id
})
end
# Actualiza una entrada existente
#
# @param id [Integer] ID de la entrada
# @param attributes [Hash] Atributos a actualizar
# @return [Hash] Entrada actualizada
def actualizar_entrada(id, attributes)
put("/api/entradas/#{id}", attributes)
end
# --- MÉTODOS PARA PROYECTOS ---
# Lista todos los proyectos
#
# @return [Array<Hash>] Lista de proyectos
def listar_proyectos
get('/api/proyectos')
end
# Obtiene un proyecto por código con detalles completos
#
# @param codigo [String] Código del proyecto (ej: P2601)
# @return [Hash] Proyecto con fases, hitos y métricas
def obtener_proyecto(codigo)
get("/api/proyectos/#{codigo}")
end
# Crea un hito en un proyecto
#
# @param codigo_proyecto [String] Código del proyecto
# @param fase_numero [Integer] Número de fase
# @param id_hito [String] ID del hito (ej: SINC01)
# @param titulo [String] Título del hito
# @param options [Hash] Opciones adicionales
# @option options [String] :descripcion Descripción del hito
# @option options [String] :estado Estado del hito (✅, ⏳, 📍)
# @option options [String] :fecha Fecha del hito (YYYY-MM-DD)
# @option options [Integer] :horas_presencial Horas presenciales
# @option options [Integer] :horas_remoto Horas remotas
# @option options [String] :nodo_nombre Nombre del nodo asociado
# @return [Hash] Hito creado
def crear_hito(codigo_proyecto, fase_numero, id_hito, titulo, options = {})
post("/api/proyectos/#{codigo_proyecto}/hitos", {
fase_numero: fase_numero,
id_hito: id_hito,
titulo: titulo,
descripcion: options[:descripcion] || '',
estado: options[:estado] || '⏳',
fecha: options[:fecha],
horas_presencial: options[:horas_presencial] || 0,
horas_remoto: options[:horas_remoto] || 0,
nodo_nombre: options[:nodo_nombre]
})
end
# --- SINCRONIZACIÓN DE ARCHIVOS MD ---
# Sincroniza un archivo Markdown con la base de datos
#
# @param ruta_md [String] Ruta al archivo .md
# @param options [Hash] Opciones de sincronización
# @option options [Boolean] :sobrescribir Sobrescribir entradas existentes
# @return [Hash] Resultado de la sincronización
def sincronizar_md(ruta_md, options = {})
unless File.exist?(ruta_md)
raise ApiError, "Archivo no encontrado: #{ruta_md}"
end
contenido = File.read(ruta_md, encoding: 'UTF-8')
nombre_archivo = File.basename(ruta_md)
# Extraer fecha del nombre del archivo
match = nombre_archivo.match(/(\d{4}-\d{2}-\d{2})\.md/)
unless match
raise ApiError, "Nombre de archivo no sigue formato YYYY-MM-DD.md: #{nombre_archivo}"
end
fecha = match[1]
@logger&.info("Sincronizando bitácora: #{fecha} desde #{ruta_md}")
# Obtener o crear bitácora
bitacora = obtener_bitacora(fecha)
bitacora_id = bitacora['id']
# Obtener mapeo de nodos por nombre
nodos = listar_nodos
nodo_map = {}
nodos.each { |n| nodo_map[n['nombre']] = n['id'] }
# Si sobrescribir, eliminar entradas existentes
if options[:sobrescribir]
entradas = listar_entradas(bitacora_id: bitacora_id)
entradas.each do |entrada|
# Podríamos implementar delete si la API lo soporta
@logger&.debug("Entrada existente que sería sobrescrita: #{entrada['id']}")
end
end
# Parsear contenido MD
entradas_encontradas = []
nodo_actual_id = nil
en_seccion_detalladas = false
contenido.each_line do |linea|
linea = linea.chomp
# Detectar inicio de sección de actividades detalladas
if linea.include?('Actividades Detalladas')
en_seccion_detalladas = true
next
end
next unless en_seccion_detalladas
# Detectar cambio de nodo
if linea.start_with?('### ')
nombre_nodo = linea.sub('### ', '').strip.split(' ')[0]
nodo_actual_id = nodo_map[nombre_nodo]
@logger&.debug("Cambiando a nodo: #{nombre_nodo} (ID: #{nodo_actual_id})") if nodo_actual_id
next
end
next unless nodo_actual_id
# Parsear entrada de tabla I-F-D-E
entrada = parsear_linea_tabla(linea)
if entrada
entrada[:bitacora_id] = bitacora_id
entrada[:nodo_id] = nodo_actual_id
begin
entrada_creada = crear_entrada(entrada)
entradas_encontradas << entrada_creada
@logger&.debug("Entrada creada: #{entrada[:inicio]} - #{entrada_creada['id']}")
rescue ApiError => e
@logger&.error("Error creando entrada: #{e.message}")
end
end
end
{
fecha: fecha,
bitacora_id: bitacora_id,
entradas_procesadas: entradas_encontradas.size,
entradas: entradas_encontradas
}
end
# --- MÉTODOS DE UTILIDAD ---
# Verifica salud de la API
#
# @return [Hash] Estado de la API
def salud
get('/health')
end
# Obtiene estadísticas del sistema
#
# @return [Hash] Estadísticas
def estadisticas
{
nodos: listar_nodos.size,
bitacoras: listar_bitacoras.size,
proyectos: listar_proyectos.size
}
end
private
# Extrae URL base desde archivo de configuración
def extraer_url_desde_config
return 'http://localhost:3002' unless File.exist?(@config_path)
begin
config = YAML.load_file(@config_path)
# Intentar obtener URL de dtic-BITACORAs
if config['dtic_bitacoras'] && config['dtic_bitacoras']['api_url']
return config['dtic_bitacoras']['api_url']
end
# Fallback a puertos predeterminados
'http://localhost:3002'
rescue => e
@logger&.error("Error cargando configuración: #{e.message}")
'http://localhost:3002'
end
end
# Parsear línea de tabla I-F-D-E
def parsear_linea_tabla(linea)
# Formato: | HH:MM | HH:MM | Descripción | [P/R] Estado |
regex = /\|\s*(\d{1,2}:\d{2})\s*\|\s*(\d{1,2}:\d{2}|\-)\s*\|\s*(.*?)\s*\|\s*(.*?)\s*\|/
match = linea.match(regex)
return nil unless match
inicio = match[1]
fin = match[2] == '-' ? nil : match[2]
descripcion_raw = match[3]
estado_raw = match[4]
# Detectar si es acción de IA
es_ia = descripcion_raw.include?('(IA)')
descripcion = descripcion_raw
.gsub('(IA)', '')
.gsub(/\*\*/, '')
.gsub(/__/, '')
.strip
# Parsear modo y estado
modo = 'P' # Default presencial
estado = estado_raw
if estado_raw.match(/\[([PR])\]\s*(.+)/)
modo = $1
estado = $2
end
{
inicio: inicio,
fin: fin,
descripcion: descripcion,
estado: estado,
modo: modo,
es_ia: es_ia
}
end
# Métodos HTTP genéricos
def get(path)
request(:get, path)
end
def get_raw(path)
request_raw(:get, path)
end
def post(path, data)
request(:post, path, data)
end
def put(path, data)
request(:put, path, data)
end
def request(method, path, data = nil)
response = request_raw(method, path, data)
JSON.parse(response)
rescue JSON::ParserError => e
raise ApiError.new("Error parseando respuesta JSON: #{e.message}", nil, response)
end
def request_raw(method, path, data = nil)
uri = URI.join(@base_url, path)
http = Net::HTTP.new(uri.host, uri.port)
http.read_timeout = @timeout_seconds
http.open_timeout = @timeout_seconds
request_class = case method
when :get then Net::HTTP::Get
when :post then Net::HTTP::Post
when :put then Net::HTTP::Put
when :delete then Net::HTTP::Delete
else raise "Método HTTP no soportado: #{method}"
end
request = request_class.new(uri.request_uri)
request['Content-Type'] = 'application/json'
request['User-Agent'] = "dtic-bitacoras-ruby-client/1.0"
request.body = data.to_json if data && !data.empty?
@logger&.debug("#{method.upcase} #{uri} #{data ? "con datos: #{data.inspect}" : ''}")
begin
response = http.request(request)
rescue Net::ReadTimeout => e
raise ApiError.new("Timeout después de #{@timeout_seconds} segundos: #{e.message}")
rescue SocketError, Errno::ECONNREFUSED => e
raise ApiError.new("No se puede conectar a #{@base_url}: #{e.message}")
end
@logger&.debug("Respuesta: #{response.code} #{response.message}")
unless response.is_a?(Net::HTTPSuccess)
error_message = "Error #{response.code}: #{response.message}"
begin
error_body = JSON.parse(response.body)
error_message += " - #{error_body['error']}" if error_body['error']
rescue JSON::ParserError
error_message += " - #{response.body[0..100]}"
end
raise ApiError.new(error_message, response.code, response.body)
end
response.body
end
end
# Helper para uso rápido
def self.client(options = {})
@client ||= Client.new(options)
end
end
# Si se ejecuta directamente como script
if __FILE__ == $0
begin
client = DticBitacorasApi::Client.new
puts "🧪 Probando conexión a #{client.instance_variable_get(:@base_url)}"
# Prueba de salud
salud = client.salud
puts "✅ API saludable: #{salud['status']}"
# Listar nodos
nodos = client.listar_nodos
puts "📊 Nodos encontrados: #{nodos.size}"
# Listar bitácoras
bitacoras = client.listar_bitacoras(limit: 5)
puts "📅 Últimas bitácoras: #{bitacoras.map { |b| b['fecha'] }.join(', ')}"
rescue DticBitacorasApi::ApiError => e
puts "❌ Error de API: #{e.message}"
exit 1
rescue DticBitacorasApi::ConfigError => e
puts "❌ Error de configuración: #{e.message}"
puts " Configura con: export DTIC_API_URL=http://localhost:3002"
exit 1
end
end
+198
View File
@@ -0,0 +1,198 @@
# config.yml — Configuración centralizada para el sistema ADN
# =============================================================
# Este archivo define la configuración para todas las herramientas
# del sistema ADN. Todas las rutas son relativas al directorio
# raíz del proyecto (dtic-srv-NS8).
#
# 📅 Última actualización: 2026-03-07 (Fase 1: Núcleo Autónomo)
# 📚 Documentación: docs/plan/260307-1400_Plan_Mejoras_ADN.md
# ─── Rutas del proyecto ─────────────────────────────────────
rutas:
# Directorio raíz del proyecto (se calcula automáticamente)
proyecto: "../.."
# Directorios de contenido
adn: "../../adn"
bitacoras: "../../bitacoras"
nodos: "../../nodos"
proyectos: "../../proyectos"
logs: "../../logs/adn"
tmp: "../../tmp"
# Herramientas existentes
ns8_candados: "../ns8-candados/ns8-candados.rb"
# ─── Configuración del validador ────────────────────────────
validador:
# Modos de operación
watch_mode: false # Monitorear cambios en tiempo real (Fase 1)
strict_mode: true # Rechazar errores graves inmediatamente
# Validaciones específicas
validar_formato_tiempo: true # Formato HH:MM (sin "hs")
validar_iconografia: true # Iconos de estado correctos
validar_metricas_hibridas: true # Presencia de [Físico/Remoto:]
validar_referencias_rollover: true # Hipervínculos bidireccionales
# Hooks Git
pre_commit_hook: true # Rechazar commits con errores (Fase 1)
pre_push_hook: false # Validación antes de push (Fase 2)
# Notificaciones
notificar_desktop: false # Notificaciones desktop (libnotify)
notificar_email: false # Notificaciones email (Fase 3)
# ─── Sistema de triggers ────────────────────────────────────
triggers:
enabled: false # Habilitado en Fase 2
config_file: "triggers.yml" # Reglas de triggers (Fase 2)
# Triggers predefinidos (se implementarán)
detectar_cambio_estado: true # Estado ⏳ → ✅ dispara resumen
detectar_nueva_bitacora: true # Crear bitácora dispara rollover
detectar_modificacion_nodo: true # Cambio en nodo actualiza ontología
# Ejecución automática
ejecutar_en_cron: false # Tareas periódicas via cron (Fase 2)
intervalo_cron: "*/5 * * * *" # Cada 5 minutos
# ─── Motor de contexto ──────────────────────────────────────
contexto:
cache_habilitado: true # Cache de análisis topológico
cache_ruta: "../../tmp/contexto-cache.json"
cache_ttl: 300 # Tiempo de vida en segundos (5 min)
# Análisis automático
analizar_topologia: true # Analizar ontología y proyectos
sugerir_comandos_ssh: true # Generar comandos con ProxyJump
detectar_requisitos_acceso: true # Identificar saltos de red
# ─── Dashboard web ──────────────────────────────────────────
dashboard:
enabled: false # Habilitado en Fase 3
port: 4567 # Puerto del servidor Sinatra
host: "localhost" # Host de acceso
auto_open: true # Abrir navegador automáticamente
# Características
websockets: true # Actualizaciones en tiempo real
metricas_tiempo_real: true # Gráficos de métricas híbridas
alertas_visuales: true # Alertas de inconsistencias
# Integración
api_json: true # API JSON para integración externa
integracion_dashboard_principal: true # Con dashboard/index.html
# ─── Sistema de logging ─────────────────────────────────────
logging:
nivel: "INFO" # DEBUG, INFO, EXITO, ADVERTENCIA, ERROR
directorio: "../../logs/adn"
# Formatos
formato_archivo: "json" # JSON estructurado
formato_terminal: "color" # color, texto, none
# Rotación
rotacion_diaria: true # Nuevo archivo cada día
retencion_dias: 30 # Conservar logs por 30 días
# Depuración
debug_detallado: false # Backtraces completos (ADN_DEBUG=1)
# ─── Sistema de backup y versionado ─────────────────────────
backup:
enabled: false # Habilitado en Fase 3
directorio_destino: "../../dtic-BKPs/adn"
# Versionado semántico
versionado_semantico: true # adn-1.2.3.tar.gz
formato_version: "major.features.fixes"
# Frecuencia
automatico: false # Backup automático
frecuencia: "daily" # daily, weekly, monthly
# Cifrado
cifrar: true # Integrar con ns8-candados
comprimir: true # Comprimir en .tar.gz
# ─── Proceso de evolución del ADN ───────────────────────────
evolucion:
# Flujo de trabajo formal
propuestas_directorio: "../../docs/plan"
periodo_revision_horas: 48 # 48 horas para revisión
# Control de versiones
tag_commits: true # Tag [ADN-EVOL] en commits
requerir_aprobacion: true # Requerir aprobación explícita
# Comunicación
notificar_bitacoras: true # Comunicar a bitácoras activas
actualizar_indice: true # Actualizar 00_indice.md automáticamente
# ─── Seguridad e integración ────────────────────────────────
seguridad:
# Integración con ns8-candados
usar_ns8_candados: true # Usar bóveda de secretos
evitar_texto_plano: true # Nunca mostrar secretos
# Auditoría
auditar_uso_secretos: false # Log de uso de secretos (Fase 3)
log_accesos: true # Registrar accesos a herramientas
# Permisos
verificar_permisos_archivos: true # Verificar chmod 0600 en configs
# ─── Configuración de desarrollo ────────────────────────────
desarrollo:
# Pruebas
ejecutar_pruebas: false # Ejecutar spec/ al desarrollar
cobertura_minima: 80 # % mínimo de cobertura
# Debug
modo_verbose: false # Salida detallada
simular_errores: false # Simular errores para testing
# Métricas
reportar_metricas_desempeno: false # Tiempos de ejecución, etc.
# ─── Integración con dtic-BITACORAs ──────────────────────────
dtic_bitacoras:
# Configuración de API
api_url: "http://localhost:3002" # URL base de la API
api_timeout: 30 # Timeout en segundos
# Sincronización automática
sincronizacion_automatica: false # Sincronizar .md → DB automáticamente
sincronizar_al_iniciar: true # Sincronizar al iniciar herramientas
# Endpoints principales
endpoints:
salud: "/health"
nodos: "/api/nodos"
bitacoras: "/api/bitacoras"
entradas: "/api/entradas"
proyectos: "/api/proyectos"
# Formato de datos
formato_estado_default: "⏳"
formato_modo_default: "P"
# Cache
cache_habilitado: true
cache_ttl: 300 # 5 minutos
# ─── Reducción de dependencia de IA ─────────────────────────
autonomia:
# KPIs objetivo (8 semanas)
kpi_acciones_ia_dia: 2 # Meta: <2 acciones IA/día
kpi_triggers_automaticos: 90 # Meta: 90% triggers automáticos
kpi_scripts_sin_ia: 85 # Meta: 85% scripts sin IA
# Transición
fase_actual: 1 # 1-4 según plan
modo_transicion: "coexistencia" # coexistencia, transferencia, autonomia
# Monitoreo
reportar_metricas_autonomia: true # Generar reportes periódicos
+340
View File
@@ -0,0 +1,340 @@
# frozen_string_literal: true
# tools/adn/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
#
+467
View File
@@ -0,0 +1,467 @@
#!/usr/bin/env bash
# Git pre-commit hook para validación del ADN del proyecto srv-ns8
# Valida que bitácoras, nodos, proyectos y hebras ADN cumplan con las reglas
# definidas en las hebras del ADN (adn/).
# ─── Configuración ───────────────────────────────────────────────────────
PROYECTO_ROOT="$(git rev-parse --show-toplevel)"
ADN_TOOLS_DIR="${PROYECTO_ROOT}/tools/adn"
ADN_CLI="${PROYECTO_ROOT}/tools/adn.rb"
CONFIG_FILE="${ADN_TOOLS_DIR}/config.yml"
HOOK_NAME="pre-commit ADN"
# ─── Colores para terminal ───────────────────────────────────────────────
COLOR_RESET="\033[0m"
COLOR_ROJO="\033[31m"
COLOR_VERDE="\033[32m"
COLOR_AMARILLO="\033[33m"
COLOR_CYAN="\033[36m"
COLOR_DIM="\033[2m"
# ─── Funciones de utilidad ───────────────────────────────────────────────
log_info() {
echo -e "${COLOR_CYAN} $1${COLOR_RESET}"
}
log_exito() {
echo -e "${COLOR_VERDE}✓ $1${COLOR_RESET}"
}
log_advertencia() {
echo -e "${COLOR_AMARILLO}⚠ $1${COLOR_RESET}"
}
log_error() {
echo -e "${COLOR_ROJO}✗ $1${COLOR_RESET}"
}
# Verificar si un comando existe
comando_existe() {
command -v "$1" >/dev/null 2>&1
}
# ─── Validaciones previas ────────────────────────────────────────────────
verificar_prerequisitos() {
# Verificar Ruby
if ! comando_existe "ruby"; then
log_error "Ruby no encontrado. Es requerido para la validación del ADN."
log_info "Instala Ruby: sudo apt install ruby (Debian/Ubuntu)"
return 1
fi
# Verificar CLI ADN
if [[ ! -f "${ADN_CLI}" ]]; then
log_error "Herramienta ADN no encontrada: ${ADN_CLI}"
log_info "Ejecuta desde la raíz del proyecto o instala las herramientas ADN."
return 1
fi
return 0
}
# ─── Obtener archivos modificados relevantes ─────────────────────────────
obtener_archivos_relevantes() {
# Obtener archivos staged con cambios (Added, Copied, Modified)
git diff --cached --name-only --diff-filter=ACM | while read -r archivo; do
# Filtrar solo archivos .md en directorios del ADN
if [[ "$archivo" =~ \.md$ ]]; then
case "$archivo" in
bitacoras/*|nodos/*|proyectos/*|adn/*)
echo "$archivo"
;;
esac
fi
done
}
# ─── Validación de archivos individuales ─────────────────────────────────
validar_archivo() {
local archivo="$1"
local nombre_archivo=$(basename "$archivo")
# Determinar tipo de validación basado en el directorio
local tipo=""
case "$archivo" in
bitacoras/*) tipo="bitacora" ;;
nodos/*) tipo="nodo" ;;
proyectos/*) tipo="proyecto" ;;
adn/*) tipo="hebra" ;;
*) return 0 ;; # No debería pasar
esac
log_info "Validando $tipo: $nombre_archivo"
# Por ahora usamos el validador general (en el futuro podría haber validación por tipo)
# El validador general ya filtra por tipo de archivo
return 0
}
# ─── Validación completa ─────────────────────────────────────────────────
ejecutar_validacion() {
local archivos_relevantes=$(obtener_archivos_relevantes)
local cantidad_archivos=$(echo "$archivos_relevantes" | wc -l | tr -d ' ')
if [[ "$cantidad_archivos" -eq 0 ]]; then
log_info "No hay archivos del ADN para validar."
return 0
fi
log_info "Validando $cantidad_archivos archivo(s) del ADN..."
# Ejecutar validador general
cd "${PROYECTO_ROOT}" || exit 1
# Ejecutar validador con opción --fix automática para correcciones menores
local output
if output=$(ruby "${ADN_CLI}" validador --fix 2>&1); then
# El validador exitó con éxito
if echo "$output" | grep -q "Con errores: 0"; then
if echo "$output" | grep -q "Con advertencias: 0"; then
log_exito "Validación del ADN completada sin errores ni advertencias."
else
log_advertencia "Validación del ADN completada con advertencias (revisa arriba)."
# Permitir commit a pesar de advertencias
fi
return 0
else
log_error "Validación del ADN encontró errores. Corrígelos antes de commit."
echo ""
echo "$output" | tail -20 # Mostrar últimas líneas del output
return 1
fi
else
# Error en la ejecución del validador
log_error "Error ejecutando el validador del ADN:"
echo "$output"
return 1
fi
}
# ─── Instalación automática (si se ejecuta con --install) ────────────────
instalar_hook() {
local hook_dest="${PROYECTO_ROOT}/.git/hooks/pre-commit"
local hook_source="${ADN_TOOLS_DIR}/hooks/pre-commit"
if [[ ! -d "${PROYECTO_ROOT}/.git" ]]; then
log_error "No se encuentra directorio .git. Ejecuta desde un repositorio Git."
return 1
fi
log_info "Instalando hook pre-commit para validación ADN..."
# Copiar hook
cp "${hook_source}" "${hook_dest}"
chmod +x "${hook_dest}"
# Verificar si ya existe un hook pre-commit previo
if [[ -f "${PROYECTO_ROOT}/.git/hooks/pre-commit" && ! -L "${PROYECTO_ROOT}/.git/hooks/pre-commit" ]] && \
! cmp -s "${hook_source}" "${hook_dest}"; then
log_advertencia "Ya existe un hook pre-commit. Se ha sobrescrito."
log_info "Si necesitas múltiples hooks, considera usar un gestor de hooks."
fi
log_exito "Hook pre-commit instalado en ${hook_dest}"
log_info "Se ejecutará automáticamente en cada 'git commit'."
}
# ─── Ayuda ───────────────────────────────────────────────────────────────
mostrar_ayuda() {
echo -e "${COLOR_CYAN}${HOOK_NAME} - Hook Git pre-commit${COLOR_RESET}"
echo ""
echo "Uso:"
echo " Ejecución automática: Se ejecuta en cada 'git commit'"
echo " Instalación: $0 --install"
echo " Validación manual: $0 --validate"
echo " Ayuda: $0 --help"
echo ""
echo "Descripción:"
echo " Valida que los archivos del ADN (bitácoras, nodos, proyectos, hebras)"
echo " cumplan con las reglas definidas en las hebras del ADN (adn/)."
echo ""
echo "Comportamiento:"
echo " - Errores: Bloquea el commit hasta que se corrijan"
echo " - Advertencias: Muestra alerta pero permite el commit"
echo " - Correcciones automáticas: Intenta corregir errores simples (--fix)"
echo ""
echo "Configuración: ${CONFIG_FILE}"
}
# ─── Main ────────────────────────────────────────────────────────────────
main() {
# Parsear argumentos
case "${1:-}" in
--install|-i)
instalar_hook
exit $?
;;
--validate|-v)
if ! verificar_prerequisitos; then
exit 1
fi
ejecutar_validacion
exit $?
;;
--help|-h)
mostrar_ayuda
exit 0
;;
"")
# Modo hook normal (sin argumentos)
if ! verificar_prerequisitos; then
exit 0 # No fallar el commit si falta Ruby, solo advertir
fi
ejecutar_validacion
exit $?
;;
*)
log_error "Argumento desconocido: $1"
mostrar_ayuda
exit 1
;;
esac
}
# Solo ejecutar main si el script es ejecutado directamente
if [[ "${BASH_SOURCE[0]}" = "$0" ]]; then
main "$@"
fi
```
Ahora necesito hacer el archivo ejecutable. El usuario tendrá que hacer `chmod +x` o nosotros podemos hacerlo en el script de instalación. Pero como esto se guardará como un archivo, el sistema lo marcará como ejecutable si tiene el shebang y permisos.
El hook incluye funcionalidad de auto-instalación con `--install`, validación manual con `--validate`, y ayuda con `--help`. También maneja casos donde falta Ruby o las herramientas ADN.
El hook filtra solo archivos .md en directorios específicos (bitacoras/, nodos/, proyectos/, adn/) y ejecuta el validador general con `--fix` para correcciones automáticas.
Si hay errores, bloquea el commit. Si solo hay advertencias, muestra alerta pero permite commit (esto es útil para transición gradual).
Ahora guardaré este contenido en el archivo especificado.<end▁of▁thinking>```
#!/usr/bin/env bash
# Git pre-commit hook para validación del ADN del proyecto srv-ns8
# Valida que bitácoras, nodos, proyectos y hebras ADN cumplan con las reglas
# definidas en las hebras del ADN (adn/).
# ─── Configuración ───────────────────────────────────────────────────────
PROYECTO_ROOT="$(git rev-parse --show-toplevel)"
ADN_TOOLS_DIR="${PROYECTO_ROOT}/tools/adn"
ADN_CLI="${PROYECTO_ROOT}/tools/adn.rb"
CONFIG_FILE="${ADN_TOOLS_DIR}/config.yml"
HOOK_NAME="pre-commit ADN"
# ─── Colores para terminal ───────────────────────────────────────────────
COLOR_RESET="\033[0m"
COLOR_ROJO="\033[31m"
COLOR_VERDE="\033[32m"
COLOR_AMARILLO="\033[33m"
COLOR_CYAN="\033[36m"
COLOR_DIM="\033[2m"
# ─── Funciones de utilidad ───────────────────────────────────────────────
log_info() {
echo -e "${COLOR_CYAN} $1${COLOR_RESET}"
}
log_exito() {
echo -e "${COLOR_VERDE}✓ $1${COLOR_RESET}"
}
log_advertencia() {
echo -e "${COLOR_AMARILLO}⚠ $1${COLOR_RESET}"
}
log_error() {
echo -e "${COLOR_ROJO}✗ $1${COLOR_RESET}"
}
# Verificar si un comando existe
comando_existe() {
command -v "$1" >/dev/null 2>&1
}
# ─── Validaciones previas ────────────────────────────────────────────────
verificar_prerequisitos() {
# Verificar Ruby
if ! comando_existe "ruby"; then
log_error "Ruby no encontrado. Es requerido para la validación del ADN."
log_info "Instala Ruby: sudo apt install ruby (Debian/Ubuntu)"
return 1
fi
# Verificar CLI ADN
if [[ ! -f "${ADN_CLI}" ]]; then
log_error "Herramienta ADN no encontrada: ${ADN_CLI}"
log_info "Ejecuta desde la raíz del proyecto o instala las herramientas ADN."
return 1
fi
return 0
}
# ─── Obtener archivos modificados relevantes ─────────────────────────────
obtener_archivos_relevantes() {
# Obtener archivos staged con cambios (Added, Copied, Modified)
git diff --cached --name-only --diff-filter=ACM | while read -r archivo; do
# Filtrar solo archivos .md en directorios del ADN
if [[ "$archivo" =~ \.md$ ]]; then
case "$archivo" in
bitacoras/*|nodos/*|proyectos/*|adn/*)
echo "$archivo"
;;
esac
fi
done
}
# ─── Validación de archivos individuales ─────────────────────────────────
validar_archivo() {
local archivo="$1"
local nombre_archivo=$(basename "$archivo")
# Determinar tipo de validación basado en el directorio
local tipo=""
case "$archivo" in
bitacoras/*) tipo="bitacora" ;;
nodos/*) tipo="nodo" ;;
proyectos/*) tipo="proyecto" ;;
adn/*) tipo="hebra" ;;
*) return 0 ;; # No debería pasar
esac
log_info "Validando $tipo: $nombre_archivo"
# Por ahora usamos el validador general (en el futuro podría haber validación por tipo)
# El validador general ya filtra por tipo de archivo
return 0
}
# ─── Validación completa ─────────────────────────────────────────────────
ejecutar_validacion() {
local archivos_relevantes=$(obtener_archivos_relevantes)
local cantidad_archivos=$(echo "$archivos_relevantes" | wc -l | tr -d ' ')
if [[ "$cantidad_archivos" -eq 0 ]]; then
log_info "No hay archivos del ADN para validar."
return 0
fi
log_info "Validando $cantidad_archivos archivo(s) del ADN..."
# Ejecutar validador general
cd "${PROYECTO_ROOT}" || exit 1
# Ejecutar validador con opción --fix automática para correcciones menores
local output
if output=$(ruby "${ADN_CLI}" validador --fix 2>&1); then
# El validador exitó con éxito
if echo "$output" | grep -q "Con errores: 0"; then
if echo "$output" | grep -q "Con advertencias: 0"; then
log_exito "Validación del ADN completada sin errores ni advertencias."
else
log_advertencia "Validación del ADN completada con advertencias (revisa arriba)."
# Permitir commit a pesar de advertencias
fi
return 0
else
log_error "Validación del ADN encontró errores. Corrígelos antes de commit."
echo ""
echo "$output" | tail -20 # Mostrar últimas líneas del output
return 1
fi
else
# Error en la ejecución del validador
log_error "Error ejecutando el validador del ADN:"
echo "$output"
return 1
fi
}
# ─── Instalación automática (si se ejecuta con --install) ────────────────
instalar_hook() {
local hook_dest="${PROYECTO_ROOT}/.git/hooks/pre-commit"
local hook_source="${ADN_TOOLS_DIR}/hooks/pre-commit"
if [[ ! -d "${PROYECTO_ROOT}/.git" ]]; then
log_error "No se encuentra directorio .git. Ejecuta desde un repositorio Git."
return 1
fi
log_info "Instalando hook pre-commit para validación ADN..."
# Copiar hook
cp "${hook_source}" "${hook_dest}"
chmod +x "${hook_dest}"
# Verificar si ya existe un hook pre-commit previo
if [[ -f "${PROYECTO_ROOT}/.git/hooks/pre-commit" && ! -L "${PROYECTO_ROOT}/.git/hooks/pre-commit" ]] && \
! cmp -s "${hook_source}" "${hook_dest}"; then
log_advertencia "Ya existe un hook pre-commit. Se ha sobrescrito."
log_info "Si necesitas múltiples hooks, considera usar un gestor de hooks."
fi
log_exito "Hook pre-commit instalado en ${hook_dest}"
log_info "Se ejecutará automáticamente en cada 'git commit'."
}
# ─── Ayuda ───────────────────────────────────────────────────────────────
mostrar_ayuda() {
echo -e "${COLOR_CYAN}${HOOK_NAME} - Hook Git pre-commit${COLOR_RESET}"
echo ""
echo "Uso:"
echo " Ejecución automática: Se ejecuta en cada 'git commit'"
echo " Instalación: $0 --install"
echo " Validación manual: $0 --validate"
echo " Ayuda: $0 --help"
echo ""
echo "Descripción:"
echo " Valida que los archivos del ADN (bitácoras, nodos, proyectos, hebras)"
echo " cumplan con las reglas definidas en las hebras del ADN (adn/)."
echo ""
echo "Comportamiento:"
echo " - Errores: Bloquea el commit hasta que se corrijan"
echo " - Advertencias: Muestra alerta pero permite el commit"
echo " - Correcciones automáticas: Intenta corregir errores simples (--fix)"
echo ""
echo "Configuración: ${CONFIG_FILE}"
}
# ─── Main ────────────────────────────────────────────────────────────────
main() {
# Parsear argumentos
case "${1:-}" in
--install|-i)
instalar_hook
exit $?
;;
--validate|-v)
if ! verificar_prerequisitos; then
exit 1
fi
ejecutar_validacion
exit $?
;;
--help|-h)
mostrar_ayuda
exit 0
;;
"")
# Modo hook normal (sin argumentos)
if ! verificar_prerequisitos; then
exit 0 # No fallar el commit si falta Ruby, solo advertir
fi
ejecutar_validacion
exit $?
;;
*)
log_error "Argumento desconocido: $1"
mostrar_ayuda
exit 1
;;
esac
}
# Solo ejecutar main si el script es ejecutado directamente
if [[ "${BASH_SOURCE[0]}" = "$0" ]]; then
main "$@"
fi
+225
View File
@@ -0,0 +1,225 @@
# frozen_string_literal: true
# tools/adn/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
# Colores ANSI para terminal (opcional)
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
# 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
# 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 tools/adn/
caller_locations.each do |loc|
ruta = loc.absolute_path || loc.path
if ruta.include?('tools/adn')
# 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
+684
View File
@@ -0,0 +1,684 @@
# frozen_string_literal: true
# tools/adn/plan/parser.rb — Parser para seguimiento de planes ADN
# ============================================================================
# Este módulo proporciona funcionalidad para parsear, actualizar y generar
# reportes del plan de trabajo ADN BITACORAs.
#
# Características:
# • Parseo de archivos Markdown con estructura de planes
# • Detección automática de fases, semanas y tareas
# • Actualización de estado de tareas ([ ] → [x])
# • Generación de reportes de progreso
# • Persistencia de cambios en el archivo original
#
# Uso:
# parser = PlanParser.new('docs/plan/260308-1200_Plan_Integral_ADN_BITACORAs.md')
# parser.parse_file
# parser.update_task_status('1.1', true)
# parser.save_changes
# puts parser.progress_report
require 'yaml'
require 'json'
require 'fileutils'
require 'time'
module ADN
module Plan
class PlanParser
# Patrones regex para parsear el archivo de plan
PHASE_PATTERN = /^###\s*([✅🟡🟠🔴🟣⚫️⬛️◼️▪️]+)?\s*\*\*FASE\s+(\d+):\s*(.+?)\*\*\s*(?:\((.+?)\))?$/i
WEEK_PATTERN = /^####\s*Semana\s+(\d+):\s*(.+)$/i
TASK_PATTERN = /^(\s*)[-*]\s*\[([ x]?)\]\s*\*\*([A-Za-z]*\d+(?:\.\d+)?):\*\*\s*(.+?)(?:\s*\((.+?)\))?$/
SECTION_PATTERN = /^\#{1,6}\s*(.+)$/
KPI_TABLE_PATTERN = /^\|\s*(.+?)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|$/
# Estados posibles de las tareas
TASK_STATES = {
' ' => :pending,
'x' => :completed,
'X' => :completed
}.freeze
# Fases del plan con sus emojis representativos
PHASE_EMOJIS = {
0 => '✅', # Preparación (completado)
1 => '🟡', # Núcleo de Conexión BD
2 => '🟠', # Herramientas CLI Avanzadas
3 => '🔴', # Sistema Integrado
4 => '🟣', # Madurez y Autonomía
5 => '⚫️' # Mantenimiento
}.freeze
attr_reader :file_path, :phases, :tasks, :kpis, :metadata, :logger
# Inicializa el parser con la ruta del archivo
#
# @param file_path [String] Ruta al archivo markdown del plan
# @param logger [Logger] Instancia de logger (opcional)
def initialize(file_path, logger = nil)
@file_path = File.expand_path(file_path)
@logger = logger || default_logger
@phases = []
@tasks = []
@kpis = []
@metadata = {}
@lines = []
@original_content = nil
@changes_made = false
end
# Parsea el archivo completo y extrae estructura
#
# @return [PlanParser] self para encadenamiento
def parse_file
unless File.exist?(@file_path)
@logger.error("Archivo de plan no encontrado: #{@file_path}")
raise "Archivo no encontrado: #{@file_path}"
end
@logger.info("Parseando archivo de plan: #{@file_path}")
@original_content = File.read(@file_path, encoding: 'UTF-8')
@lines = @original_content.lines.map(&:chomp)
debug_log("Archivo leído, #{@lines.size} líneas")
debug_log("Primeras 5 líneas: #{@lines.first(5).inspect}")
extract_metadata
parse_structure
@logger.info("Parseo completado: #{@phases.size} fases, #{@tasks.size} tareas encontradas")
if @phases.empty? || @tasks.empty?
debug_log("ADVERTENCIA: No se encontraron fases o tareas. Posibles causas:")
debug_log(" - Patrones regex no coinciden con el formato del archivo")
debug_log(" - El archivo no sigue la estructura esperada")
debug_log(" - Los encabezados de fase no coinciden con PHASE_PATTERN")
debug_log(" PHASE_PATTERN: #{PHASE_PATTERN.inspect}")
debug_log(" TASK_PATTERN: #{TASK_PATTERN.inspect}")
end
self
end
# Actualiza el estado de una tarea específica
#
# @param task_id [String] ID de la tarea (ej: "1.1", "A1")
# @param completed [Boolean] true para marcar como completada
# @param notes [String] Notas adicionales (opcional)
# @return [Boolean] true si la tarea fue encontrada y actualizada
def update_task_status(task_id, completed, notes = nil)
task = find_task_by_id(task_id)
unless task
@logger.warn("Tarea no encontrada: #{task_id}")
return false
end
new_state = completed ? 'x' : ' '
old_state = task[:state]
if new_state != old_state
# Actualizar línea en el archivo
line_index = task[:line_number] - 1
original_line = @lines[line_index]
if original_line && original_line.match(TASK_PATTERN)
updated_line = original_line.sub(/\[#{old_state}\]/, "[#{new_state}]")
# Agregar notas si se proporcionan
if notes && !notes.empty?
# Si ya hay notas, actualizarlas, sino agregar
if original_line.match(/\((.+?)\)$/)
updated_line = updated_line.sub(/\((.+?)\)$/, "(#{notes})")
else
updated_line = "#{updated_line} (#{notes})"
end
end
@lines[line_index] = updated_line
task[:state] = new_state
task[:completed] = completed
task[:notes] = notes if notes
task[:updated_at] = Time.now.iso8601
@changes_made = true
@logger.info("Tarea #{task_id} actualizada: #{old_state}#{new_state}")
# Actualizar emoji de fase si corresponde
update_phase_emoji_if_needed(task[:phase_number])
return true
end
end
false
end
# Marca múltiples tareas como completadas
#
# @param task_ids [Array<String>] IDs de las tareas
# @param notes [String] Notas para todas las tareas (opcional)
# @return [Hash] Resultado con tareas actualizadas y fallidas
def complete_tasks(task_ids, notes = nil)
results = { updated: [], failed: [] }
task_ids.each do |task_id|
if update_task_status(task_id, true, notes)
results[:updated] << task_id
else
results[:failed] << task_id
end
end
results
end
# Guarda los cambios en el archivo original
#
# @param backup [Boolean] Crear backup antes de guardar
# @return [Boolean] true si se guardaron cambios
def save_changes(backup: true)
return false unless @changes_made
if backup
create_backup
end
File.write(@file_path, @lines.join("\n") + "\n", encoding: 'UTF-8')
@changes_made = false
@logger.info("Cambios guardados en #{@file_path}")
true
end
# Genera un reporte de progreso
#
# @param format [Symbol] Formato del reporte (:text, :json, :markdown)
# @return [String] Reporte formateado
def progress_report(format: :text)
case format
when :json
generate_json_report
when :markdown
generate_markdown_report
else
generate_text_report
end
end
# Encuentra una tarea por su ID
#
# @param task_id [String] ID de la tarea
# @return [Hash, nil] Información de la tarea o nil
def find_task_by_id(task_id)
@tasks.find { |t| t[:id] == task_id }
end
# Encuentra tareas por fase
#
# @param phase_number [Integer] Número de fase
# @return [Array<Hash>] Tareas de la fase
def tasks_by_phase(phase_number)
@tasks.select { |t| t[:phase_number] == phase_number }
end
# Encuentra tareas por semana
#
# @param phase_number [Integer] Número de fase
# @param week_number [Integer] Número de semana
# @return [Array<Hash>] Tareas de la semana
def tasks_by_week(phase_number, week_number)
@tasks.select { |t| t[:phase_number] == phase_number && t[:week_number] == week_number }
end
# Calcula estadísticas de progreso
#
# @return [Hash] Estadísticas detalladas
def progress_stats
total_tasks = @tasks.size
completed_tasks = @tasks.count { |t| t[:completed] }
phase_stats = @phases.map do |phase|
phase_tasks = tasks_by_phase(phase[:number])
phase_completed = phase_tasks.count { |t| t[:completed] }
{
number: phase[:number],
name: phase[:name],
total: phase_tasks.size,
completed: phase_completed,
percentage: phase_tasks.empty? ? 0 : (phase_completed * 100 / phase_tasks.size).round(1)
}
end
week_stats = []
@phases.each do |phase|
(1..4).each do |week|
week_tasks = tasks_by_week(phase[:number], week)
next if week_tasks.empty?
week_completed = week_tasks.count { |t| t[:completed] }
week_stats << {
phase: phase[:number],
week: week,
total: week_tasks.size,
completed: week_completed,
percentage: (week_completed * 100 / week_tasks.size).round(1)
}
end
end
{
total_tasks: total_tasks,
completed_tasks: completed_tasks,
pending_tasks: total_tasks - completed_tasks,
overall_percentage: total_tasks.zero? ? 0 : (completed_tasks * 100 / total_tasks).round(1),
phases: phase_stats,
weeks: week_stats,
last_updated: Time.now.iso8601,
file_path: @file_path
}
end
# Exporta el progreso a un archivo JSON
#
# @param output_path [String] Ruta de salida (opcional)
# @return [String] JSON generado
def export_progress(output_path = nil)
export_data = {
metadata: @metadata.merge(
exported_at: Time.now.iso8601,
version: '1.0'
),
progress: progress_stats,
tasks: @tasks.map { |t| t.reject { |k| k == :line_number } },
phases: @phases
}
json_data = JSON.pretty_generate(export_data)
if output_path
File.write(output_path, json_data, encoding: 'UTF-8')
@logger.info("Progreso exportado a #{output_path}")
end
json_data
end
# Valida la integridad del plan
#
# @return [Hash] Resultados de validación
def validate_plan
errors = []
warnings = []
# Verificar que todas las tareas tengan IDs únicos
task_ids = @tasks.map { |t| t[:id] }
duplicates = task_ids.group_by { |id| id }.select { |_, ids| ids.size > 1 }.keys
duplicates.each do |duplicate_id|
errors << "ID duplicado: #{duplicate_id}"
end
# Verificar referencias a fases inexistentes
@tasks.each do |task|
unless @phases.any? { |p| p[:number] == task[:phase_number] }
warnings << "Tarea #{task[:id]} referencia fase #{task[:phase_number]} que no existe"
end
end
# Verificar tareas sin descripción
@tasks.each do |task|
if task[:description].to_s.strip.empty?
warnings << "Tarea #{task[:id]} sin descripción"
end
end
{
valid: errors.empty?,
errors: errors,
warnings: warnings,
tasks_count: @tasks.size,
phases_count: @phases.size
}
end
# Genera un resumen ejecutivo para dashboard
#
# @return [Hash] Resumen ejecutivo
def executive_summary
stats = progress_stats
next_phase = @phases.find { |p| stats[:phases].find { |ps| ps[:number] == p[:number] }[:percentage] < 100 }
next_tasks = @tasks.select { |t| !t[:completed] }.sort_by { |t| [t[:phase_number], t[:week_number]] }.first(5)
{
overall_progress: stats[:overall_percentage],
completed_tasks: stats[:completed_tasks],
pending_tasks: stats[:pending_tasks],
next_phase: next_phase ? {
number: next_phase[:number],
name: next_phase[:name],
progress: stats[:phases].find { |p| p[:number] == next_phase[:number] }[:percentage]
} : nil,
next_tasks: next_tasks.map { |t| { id: t[:id], description: t[:description] } },
estimated_completion: estimate_completion_date,
last_updated: @tasks.map { |t| t[:updated_at] }.compact.max || @metadata[:created_at]
}
end
private
# Extrae metadatos del archivo
def extract_metadata
@metadata = {
file_path: @file_path,
file_size: File.size(@file_path),
created_at: File.ctime(@file_path).iso8601,
modified_at: File.mtime(@file_path).iso8601
}
# Buscar metadatos en las primeras líneas
@lines.each_with_index do |line, index|
break if index > 20 # Solo primeras 20 líneas
case line
when /^#\s*(.+)$/
@metadata[:title] = $1.strip
when /^\*\*Fecha:\*\*\s*(.+)$/
@metadata[:date] = $1.strip
when /^\*\*Autor:\*\*\s*(.+)$/
@metadata[:author] = $1.strip
when /^\*\*Versión:\*\*\s*(.+)$/
@metadata[:version] = $1.strip
when /^\*\*Estado:\*\*\s*(.+)$/
@metadata[:status] = $1.strip
end
end
end
# Parsea la estructura completa del plan
def parse_structure
current_phase = nil
current_week = nil
current_section = nil
@lines.each_with_index do |line, line_number|
debug_log("Línea #{line_number + 1}: #{line.inspect}")
# Buscar fases
if match = line.match(PHASE_PATTERN)
debug_log(" ✓ Coincide con PHASE_PATTERN: #{match.inspect}")
current_phase = parse_phase(match, line_number + 1)
@phases << current_phase
current_week = nil
current_section = "FASE #{current_phase[:number]}"
# Buscar semanas
elsif match = line.match(WEEK_PATTERN)
debug_log(" ✓ Coincide con WEEK_PATTERN: #{match.inspect}")
current_week = match[1].to_i
current_section = "Semana #{current_week}"
# Buscar tareas
elsif match = line.match(TASK_PATTERN)
debug_log(" ✓ Coincide con TASK_PATTERN: #{match.inspect}")
if current_phase
task = parse_task(match, line_number + 1, current_phase[:number], current_week)
@tasks << task
else
debug_log(" ✗ Tarea ignorada: no hay fase actual")
end
# Buscar KPIs en tablas
elsif line.match?(KPI_TABLE_PATTERN) && !line.match?(/^[-|]/)
# Solo procesar líneas de datos de tabla (no encabezados)
if line.match?(/^\|/) && !line.match?(/^\|[-:|]/)
parse_kpi_table_line(line, line_number + 1)
end
end
end
debug_log("Estructura parseada: #{@phases.size} fases, #{@tasks.size} tareas")
end
# Parsea información de una fase
def parse_phase(match, line_number)
emoji = match[1] || PHASE_EMOJIS[match[2].to_i] || '⬜️'
number = match[2].to_i
name = match[3].strip
{
number: number,
name: name,
emoji: emoji,
line_number: line_number,
original_line: match[0]
}
end
# Parsea información de una tarea
def parse_task(match, line_number, phase_number, week_number)
indent = match[1].to_s.length
state_char = match[2].to_s
task_id = match[3].strip
description = match[4].strip
notes = match[5]
{
id: task_id,
description: description,
notes: notes,
state: state_char,
completed: TASK_STATES[state_char] == :completed,
phase_number: phase_number,
week_number: week_number,
indent_level: indent / 2, # Asume indentación de 2 espacios
line_number: line_number,
original_line: match[0],
updated_at: nil
}
end
# Parsea línea de tabla KPI
def parse_kpi_table_line(line, line_number)
return unless line.match?(KPI_TABLE_PATTERN)
parts = line.split('|').map(&:strip).reject(&:empty?)
return unless parts.size >= 5 # Necesita al menos 5 columnas para ser KPI
kpi = {
name: parts[0],
baseline: parts[1],
target_week_4: parts[2],
target_week_8: parts[3],
measurement: parts[4],
line_number: line_number
}
@kpis << kpi
end
# Actualiza el emoji de fase si todas sus tareas están completadas
def update_phase_emoji_if_needed(phase_number)
phase = @phases.find { |p| p[:number] == phase_number }
return unless phase
phase_tasks = tasks_by_phase(phase_number)
return if phase_tasks.empty?
all_completed = phase_tasks.all? { |t| t[:completed] }
new_emoji = all_completed ? '✅' : PHASE_EMOJIS[phase_number] || '⬜️'
# Actualizar línea de fase si el emoji cambió
line_index = phase[:line_number] - 1
original_line = @lines[line_index]
if original_line && original_line.match(PHASE_PATTERN)
current_emoji = $1
if current_emoji != new_emoji
updated_line = original_line.sub(/#{Regexp.escape(current_emoji)}/, new_emoji)
@lines[line_index] = updated_line
phase[:emoji] = new_emoji
@changes_made = true
@logger.info("Fase #{phase_number} actualizada: #{current_emoji}#{new_emoji}")
end
end
end
# Estima fecha de completitud basada en progreso
def estimate_completion_date
stats = progress_stats
return nil if stats[:overall_percentage] == 100 || stats[:overall_percentage] == 0
start_date = Time.parse(@metadata[:date] || @metadata[:created_at])
elapsed_days = (Time.now - start_date).to_i / 86400
if elapsed_days > 0
completion_rate = stats[:overall_percentage] / elapsed_days
remaining_percentage = 100 - stats[:overall_percentage]
estimated_days_remaining = (remaining_percentage / completion_rate).ceil
(Time.now + (estimated_days_remaining * 86400)).strftime("%Y-%m-%d")
else
nil
end
end
# Crea backup del archivo original
def create_backup
backup_dir = File.join(File.dirname(@file_path), 'backups')
FileUtils.mkdir_p(backup_dir)
timestamp = Time.now.strftime('%Y%m%d_%H%M%S')
backup_file = File.join(backup_dir, "#{File.basename(@file_path, '.md')}_#{timestamp}.md")
FileUtils.cp(@file_path, backup_file)
@logger.info("Backup creado: #{backup_file}")
end
# Genera reporte en formato texto
def generate_text_report
stats = progress_stats
report = []
report << "=" * 80
report << "REPORTE DE PROGRESO - #{@metadata[:title]}"
report << "=" * 80
report << ""
report << "📊 ESTADÍSTICAS GENERALES"
report << " • Tareas totales: #{stats[:total_tasks]}"
report << " • Completadas: #{stats[:completed_tasks]}"
report << " • Pendientes: #{stats[:pending_tasks]}"
report << " • Progreso total: #{stats[:overall_percentage]}%"
report << ""
report << "📈 PROGRESO POR FASES"
stats[:phases].each do |phase|
emoji = phase[:percentage] == 100 ? '✅' : PHASE_EMOJIS[phase[:number]] || '⬜️'
report << " #{emoji} FASE #{phase[:number]}: #{phase[:name]}"
report << " • Completadas: #{phase[:completed]}/#{phase[:total]} (#{phase[:percentage]}%)"
end
report << ""
report << "🚀 PRÓXIMAS TAREAS"
next_tasks = @tasks.select { |t| !t[:completed] }.first(5)
if next_tasks.any?
next_tasks.each do |task|
report << "#{task[:id]}: #{task[:description]}"
end
else
report << " ¡Todas las tareas están completadas! 🎉"
end
report << ""
report << "📅 ESTIMACIÓN DE COMPLETITUD"
if completion_date = estimate_completion_date
report << " • Fecha estimada: #{completion_date}"
else
report << " • Insuficientes datos para estimación"
end
report << ""
report << "🔄 ÚLTIMA ACTUALIZACIÓN: #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}"
report << "=" * 80
report.join("\n")
end
# Genera reporte en formato markdown
def generate_markdown_report
stats = progress_stats
report = []
report << "# Reporte de Progreso - #{@metadata[:title]}"
report << ""
report << "**Fecha de generación:** #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}"
report << ""
report << "## 📊 Estadísticas Generales"
report << ""
report << "| Métrica | Valor |"
report << "|---------|-------|"
report << "| Tareas totales | #{stats[:total_tasks]} |"
report << "| Tareas completadas | #{stats[:completed_tasks]} |"
report << "| Tareas pendientes | #{stats[:pending_tasks]} |"
report << "| **Progreso total** | **#{stats[:overall_percentage]}%** |"
report << ""
report << "## 📈 Progreso por Fases"
report << ""
stats[:phases].each do |phase|
emoji = phase[:percentage] == 100 ? '✅' : PHASE_EMOJIS[phase[:number]] || '⬜️'
progress_bar = "[#{'█' * (phase[:percentage] / 5)}#{'░' * (20 - phase[:percentage] / 5)}]"
report << "### #{emoji} FASE #{phase[:number]}: #{phase[:name]}"
report << ""
report << "- **Progreso:** #{progress_bar} #{phase[:percentage]}%"
report << "- **Completadas:** #{phase[:completed]}/#{phase[:total]}"
report << ""
end
report << "## 🚀 Próximas Tareas Prioritarias"
report << ""
next_tasks = @tasks.select { |t| !t[:completed] }.first(10)
if next_tasks.any?
next_tasks.each do |task|
report << "- [ ] **#{task[:id]}:** #{task[:description]}"
end
else
report << "🎉 **¡Todas las tareas están completadas!**"
end
report.join("\n")
end
# Genera reporte en formato JSON
def generate_json_report
JSON.pretty_generate(
metadata: @metadata.merge(report_generated_at: Time.now.iso8601),
progress: progress_stats,
executive_summary: executive_summary,
validation: validate_plan
)
end
# Logger por defecto
def default_logger
Class.new do
def info(msg); puts "[INFO] #{msg}"; end
def warn(msg); puts "[WARN] #{msg}"; end
def error(msg); puts "[ERROR] #{msg}"; end
def debug(msg); puts "[DEBUG] #{msg}"; end
end.new
end
# Log de depuración (solo se muestra si ADN_DEBUG está activado)
def debug_log(message)
if ENV['ADN_DEBUG'] == '1' || ENV['ADN_DEBUG'] == 'true'
@logger.debug(message) if @logger.respond_to?(:debug)
end
end
end
end
end
+936
View File
@@ -0,0 +1,936 @@
# frozen_string_literal: true
# tools/adn/subcomando_db.rb — Subcomando para operaciones con base de datos dtic-BITACORAs
# ==============================================================================
# Este subcomando integra las operaciones de base de datos PostgreSQL con la CLI ADN.
# Proporciona comandos para gestión de bitácoras, nodos, temas y entradas directamente
# en la base de datos, permitiendo la migración gradual desde archivos .md.
#
# Comandos disponibles:
# • adn db salud:bd — Verificar conexión y estado de la BD
# • adn db bitacora:crear [FECHA] — Crear nueva bitácora diaria
# • adn db bitacora:listar [--limit N] — Listar bitácoras existentes
# • adn db evento:crear [OPCIONES] — Crear nueva entrada cronológica
# • adn db evento:listar [OPCIONES] — Listar entradas con filtros
# • adn db nodo:crear [OPCIONES] — Crear nuevo nodo (servidor/VM/PC)
# • adn db nodo:listar [OPCIONES] — Listar nodos del sistema
# • adn db migrar:md [FECHA] — Migrar bitácora .md a base de datos
# • adn db estadisticas — Mostrar estadísticas del sistema
#
# Uso:
# ruby tools/adn.rb db [SUBCOMANDO] [OPCIONES]
require 'optparse'
require 'date'
require 'time'
# Cargar módulo BitacorasDB
begin
require_relative '../bitacoras-db/core/bitacora_db'
require_relative '../bitacoras-db/importers/md_importer'
rescue LoadError => e
# Fallback para desarrollo
puts "⚠ Advertencia: No se pudo cargar BitacoraDB o MDImporter: #{e.message}"
puts " Asegúrate de que los archivos en tools/bitacoras-db/ existen"
end
module ADN
class SubcomandoDB
# Inicializa el subcomando con argumentos y logger
#
# @param args [Array<String>] Argumentos de línea de comandos
# @param logger [ADNLogger] Instancia de logger para registro
def initialize(args, logger)
@args = args
@logger = logger
@config = cargar_configuracion
end
# Ejecuta el subcomando según los argumentos proporcionados
def ejecutar
if @args.empty? || @args[0] == 'help'
mostrar_ayuda
return
end
subcomando = @args[0]
sub_args = @args[1..-1] || []
case subcomando
when 'salud:bd'
comando_salud_bd
when 'bitacora:crear'
comando_bitacora_crear(sub_args)
when 'bitacora:listar'
comando_bitacora_listar(sub_args)
when 'evento:crear'
comando_evento_crear(sub_args)
when 'evento:listar'
comando_evento_listar(sub_args)
when 'nodo:crear'
comando_nodo_crear(sub_args)
when 'nodo:listar'
comando_nodo_listar(sub_args)
when 'migrar:md'
comando_migrar_md(sub_args)
when 'estadisticas'
comando_estadisticas
when 'backup'
comando_backup(sub_args)
else
@logger.error("Subcomando desconocido: #{subcomando}")
puts "#{Color::RED}✗ Subcomando desconocido: '#{subcomando}'#{Color::RESET}"
puts ""
mostrar_ayuda
exit 1
end
rescue Interrupt
puts "\n#{Color::YELLOW}⚠ Operación interrumpida por el usuario#{Color::RESET}"
@logger.info("Operación de base de datos interrumpida por usuario")
exit 130
rescue => e
@logger.error("Error en operación de base de datos: #{e.message}", { backtrace: e.backtrace })
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
puts "#{Color::DIM}#{e.backtrace.first}#{Color::RESET}" if ENV['ADN_DEBUG']
exit 1
end
private
# ─── COMANDOS PRINCIPALES ──────────────────────────────────────────────
# Comando: salud:bd - Verificar conexión y estado de la base de datos
def comando_salud_bd
puts "#{Color::CYAN}🔍 Verificando salud de la base de datos dtic-BITACORAs...#{Color::RESET}"
puts ""
begin
BitacorasDB::BitacoraDB.with_connection(env: :development) do |db|
salud = db.health_check
case salud[:status]
when 'healthy'
puts "#{Color::GREEN}✅ Conexión a PostgreSQL establecida#{Color::RESET}"
puts ""
puts " #{Color::BOLD}Versión PostgreSQL:#{Color::RESET} #{salud[:postgres_version].split(',')[0]}"
puts " #{Color::BOLD}Bitácoras en BD:#{Color::RESET} #{salud[:bitacoras_count]}"
puts " #{Color::BOLD}Servidor:#{Color::RESET} #{salud[:connection][:host]}:#{salud[:connection][:port]}"
puts " #{Color::BOLD}Base de datos:#{Color::RESET} #{salud[:connection][:database]}"
puts " #{Color::BOLD}Timestamp:#{Color::RESET} #{salud[:timestamp]}"
when 'disconnected'
puts "#{Color::RED}❌ No hay conexión activa a la base de datos#{Color::RESET}"
puts " #{Color::DIM}#{salud[:message]}#{Color::RESET}"
when 'unhealthy'
puts "#{Color::YELLOW}⚠ Problema de salud en la base de datos#{Color::RESET}"
puts " #{Color::DIM}#{salud[:message]}#{Color::RESET}"
end
end
# Verificar contenedor Docker
puts ""
puts "#{Color::CYAN}🔍 Verificando contenedor Docker...#{Color::RESET}"
if system("docker ps --filter name=postgres --format '{{.Names}}' | grep -q postgres")
puts "#{Color::GREEN}✅ Contenedor PostgreSQL en ejecución#{Color::RESET}"
else
puts "#{Color::YELLOW}⚠ Contenedor PostgreSQL no encontrado#{Color::RESET}"
puts " Ejecuta: #{Color::DIM}cd dtic-BITACORAs && docker-compose up -d#{Color::RESET}"
end
rescue PG::ConnectionBad => e
puts "#{Color::RED}❌ Error de conexión a PostgreSQL#{Color::RESET}"
puts " #{e.message}"
puts ""
puts "#{Color::YELLOW}Solución:#{Color::RESET}"
puts " 1. Verifica que el contenedor esté en ejecución:"
puts " #{Color::DIM}cd dtic-BITACORAs && docker-compose up -d#{Color::RESET}"
puts " 2. Espera 10-20 segundos para que PostgreSQL inicie"
puts " 3. Verifica credenciales en tools/config/database.yml"
rescue => e
puts "#{Color::RED}❌ Error inesperado#{Color::RESET}"
puts " #{e.message}"
end
end
# Comando: bitacora:crear [FECHA] - Crear nueva bitácora diaria
def comando_bitacora_crear(args)
opts = { fecha: Date.today, resumen: nil }
parser = OptionParser.new do |opt|
opt.banner = "Uso: adn db bitacora:crear [FECHA] [OPCIONES]"
opt.on("--resumen TEXTO", "Resumen inicial de la bitácora") do |r|
opts[:resumen] = r
end
opt.on("-h", "--help", "Mostrar ayuda") do
puts opt
exit
end
end
begin
parser.parse!(args)
# Si queda un argumento sin procesar, asumimos que es la fecha
opts[:fecha] = Date.parse(args[0]) if args[0]
rescue OptionParser::InvalidOption, Date::Error => e
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
puts parser
exit 1
end
puts "#{Color::CYAN}📝 Creando bitácora para #{opts[:fecha]}...#{Color::RESET}"
BitacorasDB::BitacoraDB.with_connection do |db|
begin
bitacora = db.create_bitacora(fecha: opts[:fecha], resumen: opts[:resumen])
puts "#{Color::GREEN}✅ Bitácora creada exitosamente#{Color::RESET}"
puts ""
puts " #{Color::BOLD}ID:#{Color::RESET} #{bitacora['id']}"
puts " #{Color::BOLD}Fecha:#{Color::RESET} #{bitacora['fecha']}"
puts " #{Color::BOLD}Resumen:#{Color::RESET} #{bitacora['resumen'] || '(sin resumen)'}"
puts " #{Color::BOLD}Creada:#{Color::RESET} #{bitacora['created_at']}"
rescue PG::UniqueViolation
puts "#{Color::YELLOW}⚠ Ya existe una bitácora para la fecha #{opts[:fecha]}#{Color::RESET}"
puts " Usa #{Color::DIM}adn db bitacora:listar#{Color::RESET} para ver bitácoras existentes"
end
end
end
# Comando: bitacora:listar [OPCIONES] - Listar bitácoras existentes
def comando_bitacora_listar(args)
opts = { limit: 10, offset: 0, desde: nil, hasta: nil }
parser = OptionParser.new do |opt|
opt.banner = "Uso: adn db bitacora:listar [OPCIONES]"
opt.on("--limit N", Integer, "Número máximo de resultados (default: 10)") do |n|
opts[:limit] = n
end
opt.on("--offset N", Integer, "Desplazamiento para paginación (default: 0)") do |n|
opts[:offset] = n
end
opt.on("--desde FECHA", "Filtrar desde fecha (YYYY-MM-DD)") do |d|
opts[:desde] = d
end
opt.on("--hasta FECHA", "Filtrar hasta fecha (YYYY-MM-DD)") do |h|
opts[:hasta] = h
end
opt.on("-h", "--help", "Mostrar ayuda") do
puts opt
exit
end
end
begin
parser.parse!(args)
rescue OptionParser::InvalidOption => e
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
puts parser
exit 1
end
puts "#{Color::CYAN}📋 Listando bitácoras...#{Color::RESET}"
BitacorasDB::BitacoraDB.with_connection do |db|
bitacoras = db.list_bitacoras(
limit: opts[:limit],
offset: opts[:offset],
desde: opts[:desde],
hasta: opts[:hasta]
)
if bitacoras.empty?
puts "#{Color::YELLOW}⚠ No se encontraron bitácoras#{Color::RESET}"
return
end
puts ""
puts "#{Color::BOLD}#{"ID".ljust(5)} #{"Fecha".ljust(12)} #{"Resumen".ljust(40)} #{"Creada".ljust(20)}#{Color::RESET}"
puts "#{"" * 5} #{"" * 12} #{"" * 40} #{"" * 20}"
bitacoras.each do |b|
fecha = b['fecha']
resumen = b['resumen'] || ''
resumen = resumen[0..37] + '...' if resumen.length > 40
creada = Time.parse(b['created_at']).strftime('%Y-%m-%d %H:%M')
puts "#{b['id'].to_s.ljust(5)} #{fecha.ljust(12)} #{resumen.ljust(40)} #{creada.ljust(20)}"
end
puts ""
puts "Total: #{bitacoras.length} bitácoras"
end
end
# Comando: evento:crear [OPCIONES] - Crear nueva entrada cronológica
def comando_evento_crear(args)
opts = {
inicio: nil,
descripcion: nil,
fin: nil,
estado: '⏳',
modo: 'P',
es_ia: false,
nodo: nil,
tema: nil
}
parser = OptionParser.new do |opt|
opt.banner = "Uso: adn db evento:crear [OPCIONES]"
opt.on("--inicio HH:MM", "Hora de inicio (requerido)") do |i|
opts[:inicio] = i
end
opt.on("--descripcion TEXTO", "Descripción de la actividad (requerido)") do |d|
opts[:descripcion] = d
end
opt.on("--fin HH:MM", "Hora de fin (opcional)") do |f|
opts[:fin] = f
end
opt.on("--estado ESTADO", "Estado (⏳, ✅, ❌, ⚠️, ⏸️)") do |e|
opts[:estado] = e
end
opt.on("--modo MODO", "Modo (P=Presencial, R=Remoto)") do |m|
opts[:modo] = m
end
opt.on("--nodo NOMBRE", "Nombre del nodo (servidor/VM)") do |n|
opts[:nodo] = n
end
opt.on("--ia", "Marcar como generado por IA") do
opts[:es_ia] = true
end
opt.on("-h", "--help", "Mostrar ayuda") do
puts opt
exit
end
end
begin
parser.parse!(args)
rescue OptionParser::InvalidOption => e
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
puts parser
exit 1
end
# Validaciones básicas
unless opts[:inicio] && opts[:descripcion]
puts "#{Color::RED}✗ Error: --inicio y --descripcion son requeridos#{Color::RESET}"
puts parser
exit 1
end
puts "#{Color::CYAN}📝 Creando nueva entrada...#{Color::RESET}"
BitacorasDB::BitacoraDB.with_connection do |db|
# Buscar nodo por nombre si se especificó
nodo_id = nil
if opts[:nodo]
nodo = db.find_nodo_by_nombre(opts[:nodo])
if nodo
nodo_id = nodo['id']
puts " #{Color::DIM}Nodo encontrado: #{opts[:nodo]} (ID: #{nodo_id})#{Color::RESET}"
else
puts "#{Color::YELLOW}⚠ Nodo '#{opts[:nodo]}' no encontrado#{Color::RESET}"
puts " Creando entrada sin nodo asociado"
end
end
# Buscar bitácora para hoy (o crearla)
fecha_hoy = Date.today.to_s
bitacora = db.find_bitacora_by_fecha(fecha_hoy)
if bitacora.nil?
puts " #{Color::DIM}Creando bitácora para hoy...#{Color::RESET}"
bitacora = db.create_bitacora(fecha: fecha_hoy)
end
bitacora_id = bitacora['id']
# Crear la entrada
entrada = db.create_entrada(
inicio: opts[:inicio],
descripcion: opts[:descripcion],
fin: opts[:fin],
estado: opts[:estado],
modo: opts[:modo],
es_ia: opts[:es_ia],
bitacora_id: bitacora_id,
nodo_id: nodo_id,
tema_id: nil # Por ahora sin tema
)
puts "#{Color::GREEN}✅ Entrada creada exitosamente#{Color::RESET}"
puts ""
puts " #{Color::BOLD}ID:#{Color::RESET} #{entrada['id']}"
puts " #{Color::BOLD}Inicio:#{Color::RESET} #{entrada['inicio']}"
puts " #{Color::BOLD}Fin:#{Color::RESET} #{entrada['fin'] || '(sin fin)'}"
puts " #{Color::BOLD}Descripción:#{Color::RESET} #{entrada['descripcion']}"
puts " #{Color::BOLD}Estado:#{Color::RESET} #{entrada['estado']}"
puts " #{Color::BOLD}Modo:#{Color::RESET} #{entrada['modo'] == 'P' ? 'Presencial' : 'Remoto'}"
puts " #{Color::BOLD}IA:#{Color::RESET} #{entrada['es_ia'] ? 'Sí' : 'No'}"
puts " #{Color::BOLD}Bitácora:#{Color::RESET} #{fecha_hoy} (ID: #{bitacora_id})"
end
end
# Comando: evento:listar [OPCIONES] - Listar entradas con filtros
def comando_evento_listar(args)
opts = { limit: 20, offset: 0, nodo: nil, estado: nil }
parser = OptionParser.new do |opt|
opt.banner = "Uso: adn db evento:listar [OPCIONES]"
opt.on("--limit N", Integer, "Número máximo de resultados (default: 20)") do |n|
opts[:limit] = n
end
opt.on("--offset N", Integer, "Desplazamiento para paginación (default: 0)") do |n|
opts[:offset] = n
end
opt.on("--nodo NOMBRE", "Filtrar por nombre de nodo") do |n|
opts[:nodo] = n
end
opt.on("--estado ESTADO", "Filtrar por estado (⏳, ✅, ❌, ⚠️, ⏸️)") do |e|
opts[:estado] = e
end
opt.on("-h", "--help", "Mostrar ayuda") do
puts opt
exit
end
end
begin
parser.parse!(args)
rescue OptionParser::InvalidOption => e
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
puts parser
exit 1
end
puts "#{Color::CYAN}📋 Listando entradas...#{Color::RESET}"
BitacorasDB::BitacoraDB.with_connection do |db|
# Obtener ID de nodo si se especificó nombre
nodo_id = nil
if opts[:nodo]
nodo = db.find_nodo_by_nombre(opts[:nodo])
if nodo
nodo_id = nodo['id']
puts " #{Color::DIM}Filtrando por nodo: #{opts[:nodo]} (ID: #{nodo_id})#{Color::RESET}"
else
puts "#{Color::YELLOW}⚠ Nodo '#{opts[:nodo]}' no encontrado#{Color::RESET}"
end
end
entradas = db.list_entradas(
nodo_id: nodo_id,
estado: opts[:estado],
limit: opts[:limit],
offset: opts[:offset]
)
if entradas.empty?
puts "#{Color::YELLOW}⚠ No se encontraron entradas#{Color::RESET}"
return
end
puts ""
puts "#{Color::BOLD}#{"ID".ljust(5)} #{"Inicio".ljust(8)} #{"Fin".ljust(8)} #{"Nodo".ljust(15)} #{"Estado".ljust(3)} #{"Descripción".ljust(40)}#{Color::RESET}"
puts "#{"" * 5} #{"" * 8} #{"" * 8} #{"" * 15} #{"" * 3} #{"" * 40}"
entradas.each do |e|
id = e['id'].to_s
inicio = e['inicio'][0..7] # HH:MM:SS -> HH:MM
fin = e['fin'] ? e['fin'][0..7] : '--:--'
nodo = e['nodo_nombre'] || '(sin nodo)'
nodo = nodo[0..13] + '...' if nodo.length > 15
estado = e['estado']
desc = e['descripcion']
desc = desc[0..37] + '...' if desc.length > 40
puts "#{id.ljust(5)} #{inicio.ljust(8)} #{fin.ljust(8)} #{nodo.ljust(15)} #{estado.ljust(3)} #{desc.ljust(40)}"
end
puts ""
puts "Total: #{entradas.length} entradas"
end
end
# Comando: nodo:crear [OPCIONES] - Crear nuevo nodo
def comando_nodo_crear(args)
opts = { nombre: nil, ip: nil, tipo: 'servidor', descripcion: nil, activo: true }
parser = OptionParser.new do |opt|
opt.banner = "Uso: adn db nodo:crear [OPCIONES]"
opt.on("--nombre NOMBRE", "Nombre único del nodo (requerido)") do |n|
opts[:nombre] = n
end
opt.on("--ip DIRECCION", "Dirección IP (opcional)") do |i|
opts[:ip] = i
end
opt.on("--tipo TIPO", "Tipo (servidor, hipervisor, vm, pc)") do |t|
opts[:tipo] = t
end
opt.on("--descripcion TEXTO", "Descripción del nodo") do |d|
opts[:descripcion] = d
end
opt.on("--inactivo", "Crear nodo como inactivo") do
opts[:activo] = false
end
opt.on("-h", "--help", "Mostrar ayuda") do
puts opt
exit
end
end
begin
parser.parse!(args)
rescue OptionParser::InvalidOption => e
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
puts parser
exit 1
end
unless opts[:nombre]
puts "#{Color::RED}✗ Error: --nombre es requerido#{Color::RESET}"
puts parser
exit 1
end
puts "#{Color::CYAN}🖥️ Creando nodo '#{opts[:nombre]}'...#{Color::RESET}"
BitacorasDB::BitacoraDB.with_connection do |db|
begin
nodo = db.create_nodo(
nombre: opts[:nombre],
ip: opts[:ip],
tipo: opts[:tipo],
descripcion: opts[:descripcion],
activo: opts[:activo]
)
puts "#{Color::GREEN}✅ Nodo creado exitosamente#{Color::RESET}"
puts ""
puts " #{Color::BOLD}ID:#{Color::RESET} #{nodo['id']}"
puts " #{Color::BOLD}Nombre:#{Color::RESET} #{nodo['nombre']}"
puts " #{Color::BOLD}IP:#{Color::RESET} #{nodo['ip'] || '(sin IP)'}"
puts " #{Color::BOLD}Tipo:#{Color::RESET} #{nodo['tipo']}"
puts " #{Color::BOLD}Activo:#{Color::RESET} #{nodo['activo'] ? 'Sí' : 'No'}"
puts " #{Color::BOLD}Descripción:#{Color::RESET} #{nodo['descripcion'] || '(sin descripción)'}"
puts " #{Color::BOLD}Creado:#{Color::RESET} #{nodo['created_at']}"
rescue PG::UniqueViolation
puts "#{Color::YELLOW}⚠ Ya existe un nodo con el nombre '#{opts[:nombre]}'#{Color::RESET}"
puts " Usa #{Color::DIM}adn db nodo:listar#{Color::RESET} para ver nodos existentes"
rescue => e
puts "#{Color::RED}✗ Error creando nodo: #{e.message}#{Color::RESET}"
end
end
end
# Comando: nodo:listar [OPCIONES] - Listar nodos del sistema
def comando_nodo_listar(args)
opts = { activo: nil, tipo: nil }
parser = OptionParser.new do |opt|
opt.banner = "Uso: adn db nodo:listar [OPCIONES]"
opt.on("--activo", "Mostrar solo nodos activos") do
opts[:activo] = true
end
opt.on("--inactivo", "Mostrar solo nodos inactivos") do
opts[:activo] = false
end
opt.on("--tipo TIPO", "Filtrar por tipo (servidor, hipervisor, vm, pc)") do |t|
opts[:tipo] = t
end
opt.on("-h", "--help", "Mostrar ayuda") do
puts opt
exit
end
end
begin
parser.parse!(args)
rescue OptionParser::InvalidOption => e
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
puts parser
exit 1
end
puts "#{Color::CYAN}🖥️ Listando nodos...#{Color::RESET}"
BitacorasDB::BitacoraDB.with_connection do |db|
nodos = db.list_nodos(activo: opts[:activo], tipo: opts[:tipo], limit: 100)
if nodos.empty?
puts "#{Color::YELLOW}⚠ No se encontraron nodos#{Color::RESET}"
return
end
puts ""
puts "#{Color::BOLD}#{"ID".ljust(5)} #{"Nombre".ljust(20)} #{"IP".ljust(15)} #{"Tipo".ljust(12)} #{"Activo".ljust(6)} #{"Descripción".ljust(30)}#{Color::RESET}"
puts "#{"" * 5} #{"" * 20} #{"" * 15} #{"" * 12} #{"" * 6} #{"" * 30}"
nodos.each do |n|
id = n['id'].to_s
nombre = n['nombre']
ip = n['ip'] || '(sin IP)'
tipo = n['tipo']
activo = n['activo'] ? '✅' : '❌'
desc = n['descripcion'] || ''
desc = desc[0..27] + '...' if desc.length > 30
puts "#{id.ljust(5)} #{nombre.ljust(20)} #{ip.ljust(15)} #{tipo.ljust(12)} #{activo.ljust(6)} #{desc.ljust(30)}"
end
puts ""
puts "Total: #{nodos.length} nodos"
# Estadísticas por tipo
tipos = nodos.group_by { |n| n['tipo'] }
puts "#{Color::DIM}Tipos: #{tipos.map { |t, ns| "#{t}: #{ns.length}" }.join(', ')}#{Color::RESET}"
end
end
# Comando: migrar:md [FECHA] - Migrar bitácora .md a base de datos
def comando_migrar_md(args)
opts = { sobrescribir: false, crear_nodos: true, todo: false }
parser = OptionParser.new do |opt|
opt.banner = "Uso: adn db migrar:md [FECHA] [OPCIONES]"
opt.on("--sobrescribir", "Sobrescribir bitácora si ya existe") do
opts[:sobrescribir] = true
end
opt.on("--no-crear-nodos", "No crear nodos automáticamente") do
opts[:crear_nodos] = false
end
opt.on("--todo", "Importar todas las bitácoras del directorio") do
opts[:todo] = true
end
opt.on("-h", "--help", "Mostrar ayuda") do
puts opt
exit
end
end
begin
parser.parse!(args)
rescue OptionParser::InvalidOption => e
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
puts parser
exit 1
end
# Si se especificó --todo, importar todas las bitácoras
if opts[:todo]
importar_todas_bitacoras(opts)
return
end
# Si no quedan argumentos, mostrar ayuda
if args.empty?
puts "#{Color::YELLOW}⚠ Se requiere fecha o --todo#{Color::RESET}"
puts parser
exit 1
end
fecha_arg = args[0]
begin
fecha = Date.parse(fecha_arg)
rescue Date::Error
puts "#{Color::RED}✗ Formato de fecha inválido: #{fecha_arg}#{Color::RESET}"
puts " Usa formato YYYY-MM-DD (ej: 2026-03-07)"
exit 1
end
archivo_md = File.join(ADN::BITACORAS_DIR, "#{fecha}.md")
unless File.exist?(archivo_md)
puts "#{Color::RED}✗ Archivo de bitácora no encontrado: #{archivo_md}#{Color::RESET}"
puts " Verifica que la bitácora .md existe en #{ADN::BITACORAS_DIR}/"
exit 1
end
puts "#{Color::CYAN}🔄 Migrando bitácora #{fecha} de .md a base de datos...#{Color::RESET}"
puts " #{Color::DIM}Opciones: sobrescribir=#{opts[:sobrescribir]}, crear_nodos=#{opts[:crear_nodos]}#{Color::RESET}"
BitacorasDB::BitacoraDB.with_connection do |db|
begin
# Crear instancia del importador
importer = BitacorasDB::Importers::MDImporter.new(db, @logger)
# Importar bitácora
resultado = importer.importar_bitacora(
archivo_md,
sobrescribir: opts[:sobrescribir],
crear_nodos: opts[:crear_nodos]
)
mostrar_resultado_migracion(resultado, fecha)
rescue => e
puts "#{Color::RED}✗ Error durante migración: #{e.message}#{Color::RESET}"
puts "#{Color::DIM}#{e.backtrace.first}#{Color::RESET}" if ENV['ADN_DEBUG']
end
end
end
# Comando: estadisticas - Mostrar estadísticas del sistema
def comando_estadisticas
puts "#{Color::CYAN}📊 Estadísticas del sistema dtic-BITACORAs#{Color::RESET}"
BitacorasDB::BitacoraDB.with_connection do |db|
stats = db.statistics
puts ""
puts "#{Color::BOLD}📈 Conteos de tablas:#{Color::RESET}"
puts " #{Color::DIM}Nodos:#{Color::RESET} #{stats[:nodos] || 0}"
puts " #{Color::DIM}Bitácoras:#{Color::RESET} #{stats[:bitacoras] || 0}"
puts " #{Color::DIM}Temas:#{Color::RESET} #{stats[:temas] || 0}"
puts " #{Color::DIM}Entradas:#{Color::RESET} #{stats[:entradas] || 0}"
if stats[:nodos_con_entradas]
puts ""
puts "#{Color::BOLD}📊 Estadísticas de actividad:#{Color::RESET}"
puts " #{Color::DIM}Nodos con entradas:#{Color::RESET} #{stats[:nodos_con_entradas]}"
puts " #{Color::DIM}Días con actividad:#{Color::RESET} #{stats[:dias_con_actividad]}"
puts " #{Color::DIM}Entradas generadas por IA:#{Color::RESET} #{stats[:entradas_ia]}"
puts " #{Color::DIM}Entradas completadas:#{Color::RESET} #{stats[:entradas_completadas]}"
end
# Calcular porcentajes
total_entradas = stats[:entradas] || 0
if total_entradas > 0
porcentaje_ia = (stats[:entradas_ia] || 0) * 100 / total_entradas
porcentaje_completadas = (stats[:entradas_completadas] || 0) * 100 / total_entradas
puts ""
puts "#{Color::BOLD}📈 Porcentajes:#{Color::RESET}"
puts " #{Color::DIM}Entradas IA:#{Color::RESET} #{porcentaje_ia}%"
puts " #{Color::DIM}Entradas completadas:#{Color::RESET} #{porcentaje_completadas}%"
end
end
def comando_backup(args)
opts = { output: nil, compress: true, timestamp: true }
parser = OptionParser.new do |opt|
opt.banner = "Uso: adn db backup [OPCIONES]"
opt.on("--output FILE", "Archivo de salida (default: backup_YYYYMMDD_HHMMSS.sql)") do |file|
opts[:output] = file
end
opt.on("--no-compress", "No comprimir el archivo de backup") do
opts[:compress] = false
end
opt.on("--no-timestamp", "No agregar timestamp al nombre del archivo") do
opts[:timestamp] = false
end
opt.on("-h", "--help", "Mostrar ayuda") do
puts opt
exit
end
end
begin
parser.parse!(args)
rescue OptionParser::InvalidOption => e
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
puts parser
exit 1
end
puts "#{Color::CYAN}💾 Creando backup de la base de datos dtic-BITACORAs...#{Color::RESET}"
puts " #{Color::DIM}Compresión: #{opts[:compress]}, Timestamp: #{opts[:timestamp]}#{Color::RESET}"
# Cargar configuración de base de datos
config_path = File.join(__dir__, '..', '..', 'config', 'database.yml')
unless File.exist?(config_path)
puts "#{Color::RED}✗ Archivo de configuración no encontrado: #{config_path}#{Color::RESET}"
exit 1
end
begin
config = YAML.load_file(config_path)
db_config = config['development'] || config.first[1] # Tomar primer entorno si no hay development
host = db_config['host'] || 'localhost'
port = db_config['port'] || 5433
database = db_config['database']
username = db_config['username']
password = db_config['password']
unless database && username
puts "#{Color::RED}✗ Configuración de base de datos incompleta#{Color::RESET}"
exit 1
end
# Generar nombre de archivo
timestamp = Time.now.strftime("%Y%m%d_%H%M%S")
base_name = "dtic_bitacoras_backup_#{timestamp}"
sql_file = opts[:output] || "#{base_name}.sql"
final_file = opts[:compress] ? "#{sql_file}.gz" : sql_file
puts " #{Color::DIM}Archivo de salida: #{final_file}#{Color::RESET}"
# Construir comando pg_dump
dump_cmd = "PGPASSWORD=\"#{password}\" pg_dump"
dump_cmd += " -h #{host} -p #{port} -U #{username} -d #{database}"
dump_cmd += " --schema=bitacoras" # Solo esquema bitacoras
dump_cmd += " --clean --if-exists" # Incluir DROP statements
dump_cmd += " --no-owner --no-privileges" # No incluir permisos
dump_cmd += " --inserts" # Usar INSERT en lugar de COPY
if opts[:compress]
dump_cmd += " | gzip -c > \"#{final_file}\""
else
dump_cmd += " > \"#{final_file}\""
end
# Ejecutar backup
puts " #{Color::DIM}Ejecutando pg_dump...#{Color::RESET}"
system(dump_cmd)
if $?.success?
file_size = File.size(final_file) rescue 0
size_str = file_size > 0 ? " (#{file_size / 1024} KB)" : ""
puts "#{Color::GREEN}✅ Backup creado exitosamente: #{final_file}#{size_str}#{Color::RESET}"
puts " #{Color::DIM}Para restaurar:#{Color::RESET} gunzip -c #{final_file} | psql -h #{host} -p #{port} -U #{username} -d #{database}"
else
puts "#{Color::RED}✗ Error creando backup#{Color::RESET}"
# Limpiar archivo parcial si existe
File.delete(final_file) if File.exist?(final_file)
exit 1
end
rescue => e
puts "#{Color::RED}✗ Error durante backup: #{e.message}#{Color::RESET}"
puts "#{Color::DIM}#{e.backtrace.first}#{Color::RESET}" if ENV['ADN_DEBUG']
exit 1
end
end
end
# Importa todas las bitácoras del directorio bitacoras/
def importar_todas_bitacoras(opciones)
puts "#{Color::CYAN}🔄 Migrando todas las bitácoras .md a base de datos...#{Color::RESET}"
puts " #{Color::DIM}Opciones: sobrescribir=#{opciones[:sobrescribir]}, crear_nodos=#{opciones[:crear_nodos]}#{Color::RESET}"
puts ""
BitacorasDB::BitacoraDB.with_connection do |db|
# Crear instancia del importador
importer = BitacorasDB::Importers::MDImporter.new(db, @logger)
# Importar directorio completo
resultado = importer.importar_directorio(
ADN::BITACORAS_DIR,
sobrescribir: opciones[:sobrescribir],
crear_nodos: opciones[:crear_nodos]
)
# Mostrar resultados consolidados
totales = resultado[:totales]
estadisticas = resultado[:estadisticas]
puts ""
puts "#{Color::BOLD}📊 Resultado de migración masiva:#{Color::RESET}"
puts " #{Color::DIM}Archivos procesados:#{Color::RESET} #{totales[:archivos]}"
puts " #{Color::DIM}Migraciones exitosas:#{Color::RESET} #{totales[:exitosos]}"
puts " #{Color::DIM}Omitidos (ya existían):#{Color::RESET} #{totales[:omitidos]}"
puts " #{Color::DIM}Errores:#{Color::RESET} #{totales[:errores]}"
if estadisticas[:errores].any?
puts ""
puts "#{Color::YELLOW}⚠ Errores encontrados:#{Color::RESET}"
estadisticas[:errores].each_with_index do |error, idx|
puts " #{idx + 1}. #{error}"
end
end
puts ""
puts "#{Color::BOLD}📈 Estadísticas acumuladas:#{Color::RESET}"
puts " #{Color::DIM}Bitácoras creadas:#{Color::RESET} #{estadisticas[:bitacoras_creadas]}"
puts " #{Color::DIM}Nodos creados:#{Color::RESET} #{estadisticas[:nodos_creados]}"
puts " #{Color::DIM}Temas creados:#{Color::RESET} #{estadisticas[:temas_creados]}"
puts " #{Color::DIM}Entradas creadas:#{Color::RESET} #{estadisticas[:entradas_creadas]}"
if totales[:errores] > 0
puts ""
puts "#{Color::YELLOW}⚠ Algunas bitácoras no se migraron correctamente#{Color::RESET}"
puts " Revisa los errores arriba y corrige los problemas"
elsif totales[:exitosos] > 0
puts ""
puts "#{Color::GREEN}✅ Migración masiva completada exitosamente#{Color::RESET}"
end
end
rescue => e
puts "#{Color::RED}✗ Error durante migración masiva: #{e.message}#{Color::RESET}"
puts "#{Color::DIM}#{e.backtrace.first}#{Color::RESET}" if ENV['ADN_DEBUG']
end
# Muestra el resultado de una migración individual
def mostrar_resultado_migracion(resultado, fecha)
if resultado[:exitoso]
puts "#{Color::GREEN}✅ Bitácora migrada exitosamente#{Color::RESET}"
puts ""
puts " #{Color::BOLD}Fecha:#{Color::RESET} #{fecha}"
puts " #{Color::BOLD}Bitácora ID:#{Color::RESET} #{resultado[:bitacora_id]}" if resultado[:bitacora_id]
puts " #{Color::BOLD}Estadísticas:#{Color::RESET}"
puts " • Nodos creados: #{resultado.dig(:estadisticas, :nodos_creados) || 0}"
puts " • Temas creados: #{resultado.dig(:estadisticas, :temas_creados) || 0}"
puts " • Entradas creadas: #{resultado.dig(:estadisticas, :entradas_creadas) || 0}"
elsif resultado[:skip]
puts "#{Color::YELLOW}⚠ Bitácora ya existe, omitiendo#{Color::RESET}"
puts " Usa --sobrescribir para forzar migración"
puts " Bitácora ID: #{resultado[:bitacora_id]}" if resultado[:bitacora_id]
else
puts "#{Color::RED}✗ Error migrando bitácora#{Color::RESET}"
if resultado[:error]
puts " #{resultado[:error]}"
end
if resultado[:errores]&.any?
resultado[:errores].each do |error|
puts "#{error}"
end
end
end
end
# ─── MÉTODOS AUXILIARES ──────────────────────────────────────────────
# Carga la configuración desde config.yml
def cargar_configuracion
config_path = File.join(__dir__, 'config.yml')
return {} unless File.exist?(config_path)
begin
YAML.load_file(config_path)
rescue => e
@logger.error("Error cargando configuración: #{e.message}")
{}
end
end
# Muestra la ayuda general del subcomando
def mostrar_ayuda
puts "#{Color::BOLD}Uso: ruby tools/adn.rb db [SUBCOMANDO] [OPCIONES]#{Color::RESET}"
puts ""
puts "#{Color::BOLD}Subcomandos disponibles:#{Color::RESET}"
puts ""
puts " #{Color::CYAN}salud:bd#{Color::RESET} Verificar conexión y estado de la BD"
puts " #{Color::CYAN}backup [OPCIONES]#{Color::RESET} Crear backup de la base de datos"
puts " #{Color::CYAN}bitacora:crear [FECHA]#{Color::RESET} Crear nueva bitácora diaria"
puts " #{Color::CYAN}bitacora:listar [OPCIONES]#{Color::RESET} Listar bitácoras existentes"
puts " #{Color::CYAN}evento:crear [OPCIONES]#{Color::RESET} Crear nueva entrada cronológica"
puts " #{Color::CYAN}evento:listar [OPCIONES]#{Color::RESET} Listar entradas con filtros"
puts " #{Color::CYAN}nodo:crear [OPCIONES]#{Color::RESET} Crear nuevo nodo (servidor/VM/PC)"
puts " #{Color::CYAN}nodo:listar [OPCIONES]#{Color::RESET} Listar nodos del sistema"
puts " #{Color::CYAN}migrar:md [FECHA]#{Color::RESET} Migrar bitácora .md a base de datos"
puts " #{Color::CYAN}estadisticas#{Color::RESET} Mostrar estadísticas del sistema"
puts ""
puts "#{Color::BOLD}Ejemplos:#{Color::RESET}"
puts " ruby tools/adn.rb db salud:bd"
puts " ruby tools/adn.rb db bitacora:crear --resumen \"Inicio de proyecto\""
puts " ruby tools/adn.rb db evento:crear --inicio 09:00 --descripcion \"Reunión\" --nodo srv-ns8"
puts " ruby tools/adn.rb db nodo:listar --tipo servidor"
puts ""
puts "#{Color::YELLOW}⚠ Nota:#{Color::RESET} Requiere PostgreSQL en ejecución (puerto 5433)"
puts " Iniciar contenedor: #{Color::DIM}cd dtic-BITACORAs && docker-compose up -d#{Color::RESET}"
end
end
end
+517
View File
@@ -0,0 +1,517 @@
# frozen_string_literal: true
# tools/adn/subcomando_plan.rb — Subcomando para seguimiento y gestión del plan ADN
# ==============================================================================
# Este subcomando proporciona herramientas para el seguimiento, actualización y
# reporte del plan integral de integración ADN con dtic-BITACORAs.
#
# Características principales:
# • Parseo y análisis del archivo de plan Markdown
# • Actualización del estado de tareas (pendiente/completado)
# • Generación de reportes de progreso en múltiples formatos
# • Validación de integridad del plan
# • Exportación de datos de progreso
#
# Comandos disponibles:
# • adn plan progreso [OPCIONES] — Mostrar progreso actual del plan
# • adn plan actualizar [TAREAS] — Marcar tareas como completadas
# • adn plan reporte [--formato] — Generar reporte detallado
# • adn plan validar — Validar integridad del plan
# • adn plan exportar [--output] — Exportar datos de progreso
# • adn plan ayuda — Mostrar ayuda completa
#
# Uso:
# ruby tools/adn.rb plan [SUBCOMANDO] [OPCIONES]
require 'optparse'
require 'date'
require 'time'
require 'json'
require 'yaml'
# Cargar módulo PlanParser
begin
require_relative 'plan/parser'
rescue LoadError => e
puts "⚠ Advertencia: No se pudo cargar PlanParser: #{e.message}"
puts " Asegúrate de que tools/adn/plan/parser.rb existe"
end
module ADN
class SubcomandoPlan
# Colores ANSI para terminal (consistentes con adn.rb)
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
# Ruta por defecto del archivo de plan
DEFAULT_PLAN_PATH = File.expand_path('../../docs/plan/260308-1200_Plan_Integral_ADN_BITACORAs.md', __dir__)
# Inicializa el subcomando con argumentos y logger
#
# @param args [Array<String>] Argumentos de línea de comandos
# @param logger [ADNLogger] Instancia de logger para registro
def initialize(args, logger)
@args = args
@logger = logger
@plan_path = DEFAULT_PLAN_PATH
end
# Ejecuta el subcomando según los argumentos proporcionados
def ejecutar
if @args.empty? || @args[0] == 'ayuda' || @args[0] == 'help' || @args[0] == '-h' || @args[0] == '--help'
mostrar_ayuda
return
end
subcomando = @args[0]
sub_args = @args[1..-1] || []
case subcomando
when 'progreso'
comando_progreso(sub_args)
when 'actualizar'
comando_actualizar(sub_args)
when 'reporte'
comando_reporte(sub_args)
when 'validar'
comando_validar(sub_args)
when 'exportar'
comando_exportar(sub_args)
else
@logger.error("Subcomando desconocido: #{subcomando}")
puts "#{Color::RED}✗ Subcomando desconocido: '#{subcomando}'#{Color::RESET}"
puts ""
mostrar_ayuda
exit 1
end
rescue Interrupt
puts "\n#{Color::YELLOW}⚠ Operación interrumpida por el usuario#{Color::RESET}"
@logger.info("Operación de plan interrumpida por usuario")
exit 130
rescue => e
@logger.error("Error en operación de plan: #{e.message}", { backtrace: e.backtrace })
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
puts "#{Color::DIM}#{e.backtrace.first}#{Color::RESET}" if ENV['ADN_DEBUG']
exit 1
end
private
# Comando: progreso - Mostrar progreso actual del plan
def comando_progreso(args)
opts = { detallado: false, formato: 'texto' }
parser = OptionParser.new do |opt|
opt.banner = "Uso: adn plan progreso [OPCIONES]"
opt.on("--detallado", "Mostrar progreso detallado por fase y semana") do
opts[:detallado] = true
end
opt.on("--formato FORMATO", "Formato de salida (texto, json, markdown)") do |f|
opts[:formato] = f.downcase
end
opt.on("--plan RUTA", "Ruta al archivo de plan (default: #{File.basename(@plan_path)})") do |ruta|
@plan_path = File.expand_path(ruta)
end
opt.on("-h", "--help", "Mostrar ayuda") do
puts opt
exit
end
end
begin
parser.parse!(args)
rescue OptionParser::InvalidOption => e
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
puts parser
exit 1
end
puts "#{Color::CYAN}📊 Analizando progreso del plan ADN BITACORAs...#{Color::RESET}"
puts " #{Color::DIM}Archivo: #{@plan_path}#{Color::RESET}"
puts ""
begin
parser_obj = Plan::PlanParser.new(@plan_path, @logger)
parser_obj.parse_file
if opts[:formato] == 'json'
puts parser_obj.progress_report(format: :json)
elsif opts[:formato] == 'markdown'
puts parser_obj.progress_report(format: :markdown)
else
# Formato texto (default)
puts parser_obj.progress_report(format: :text)
if opts[:detallado]
puts ""
puts "#{Color::BOLD}📈 DETALLE POR FASES Y SEMANAS:#{Color::RESET}"
stats = parser_obj.progress_stats
stats[:phases].each do |phase|
puts ""
puts " #{Color::CYAN}FASE #{phase[:number]}: #{phase[:name]}#{Color::RESET}"
puts " Progreso: #{phase[:percentage]}% (#{phase[:completed]}/#{phase[:total]} tareas)"
# Mostrar tareas de esta fase
phase_tasks = parser_obj.tasks_by_phase(phase[:number])
completed_tasks = phase_tasks.select { |t| t[:completed] }
pending_tasks = phase_tasks.reject { |t| t[:completed] }
if completed_tasks.any?
puts " #{Color::GREEN}✓ Completadas:#{Color::RESET}"
completed_tasks.first(3).each do |task|
puts "#{task[:id]}: #{task[:description]}"
end
if completed_tasks.size > 3
puts " ... y #{completed_tasks.size - 3} más"
end
end
if pending_tasks.any?
puts " #{Color::YELLOW}⏳ Pendientes:#{Color::RESET}"
pending_tasks.first(3).each do |task|
puts "#{task[:id]}: #{task[:description]}"
end
if pending_tasks.size > 3
puts " ... y #{pending_tasks.size - 3} más"
end
end
end
end
end
rescue => e
puts "#{Color::RED}✗ Error analizando el plan: #{e.message}#{Color::RESET}"
puts " #{Color::DIM}Asegúrate de que el archivo existe y tiene el formato correcto#{Color::RESET}"
exit 1
end
end
# Comando: actualizar - Marcar tareas como completadas
def comando_actualizar(args)
if args.empty?
puts "#{Color::YELLOW}⚠ Se requiere al menos una tarea para actualizar#{Color::RESET}"
puts " Uso: adn plan actualizar TAREA1 [TAREA2 ...] [OPCIONES]"
exit 1
end
opts = { notas: nil, guardar: true }
parser = OptionParser.new do |opt|
opt.banner = "Uso: adn plan actualizar TAREA1 [TAREA2 ...] [OPCIONES]"
opt.on("--notas TEXTO", "Agregar notas a las tareas actualizadas") do |texto|
opts[:notas] = texto
end
opt.on("--no-guardar", "No guardar cambios en el archivo (solo simular)") do
opts[:guardar] = false
end
opt.on("--plan RUTA", "Ruta al archivo de plan") do |ruta|
@plan_path = File.expand_path(ruta)
end
opt.on("-h", "--help", "Mostrar ayuda") do
puts opt
exit
end
end
# Separar tareas de opciones
task_args = []
begin
parser.parse!(args)
task_args = args # Los argumentos restantes son las tareas
rescue OptionParser::InvalidOption => e
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
puts parser
exit 1
end
if task_args.empty?
puts "#{Color::YELLOW}⚠ No se especificaron tareas para actualizar#{Color::RESET}"
puts " Ejemplo: adn plan actualizar 1.1 1.2 2.3"
exit 1
end
puts "#{Color::CYAN}🔄 Actualizando tareas del plan ADN BITACORAs...#{Color::RESET}"
puts " #{Color::DIM}Tareas: #{task_args.join(', ')}#{Color::RESET}"
puts " #{Color::DIM}Notas: #{opts[:notas] || '(ninguna)'}#{Color::RESET}"
puts " #{Color::DIM}Guardar: #{opts[:guardar]}#{Color::RESET}"
puts ""
begin
parser_obj = Plan::PlanParser.new(@plan_path, @logger)
parser_obj.parse_file
resultados = parser_obj.complete_tasks(task_args, opts[:notas])
if resultados[:updated].any?
puts "#{Color::GREEN}✅ Tareas actualizadas exitosamente:#{Color::RESET}"
resultados[:updated].each do |task_id|
task = parser_obj.find_task_by_id(task_id)
puts "#{task_id}: #{task[:description]}"
end
if opts[:guardar]
if parser_obj.save_changes
puts ""
puts "#{Color::GREEN}✓ Cambios guardados en #{@plan_path}#{Color::RESET}"
else
puts ""
puts "#{Color::YELLOW}⚠ No se realizaron cambios o no se pudieron guardar#{Color::RESET}"
end
else
puts ""
puts "#{Color::YELLOW}⚠ Modo simulación - cambios no guardados#{Color::RESET}"
puts " Usa --guardar para persistir los cambios"
end
end
if resultados[:failed].any?
puts ""
puts "#{Color::YELLOW}⚠ Tareas no encontradas:#{Color::RESET}"
resultados[:failed].each do |task_id|
puts "#{task_id}"
end
end
# Mostrar progreso actualizado
if resultados[:updated].any?
puts ""
stats = parser_obj.progress_stats
puts " #{Color::BOLD}Progreso actualizado: #{stats[:overall_percentage]}%#{Color::RESET}"
puts " (#{stats[:completed_tasks]}/#{stats[:total_tasks]} tareas completadas)"
end
rescue => e
puts "#{Color::RED}✗ Error actualizando tareas: #{e.message}#{Color::RESET}"
exit 1
end
end
# Comando: reporte - Generar reporte detallado
def comando_reporte(args)
opts = { formato: 'texto', output: nil }
parser = OptionParser.new do |opt|
opt.banner = "Uso: adn plan reporte [OPCIONES]"
opt.on("--formato FORMATO", "Formato del reporte (texto, json, markdown)") do |f|
opts[:formato] = f.downcase
end
opt.on("--output ARCHIVO", "Guardar reporte en archivo (en lugar de mostrar)") do |archivo|
opts[:output] = archivo
end
opt.on("--plan RUTA", "Ruta al archivo de plan") do |ruta|
@plan_path = File.expand_path(ruta)
end
opt.on("-h", "--help", "Mostrar ayuda") do
puts opt
exit
end
end
begin
parser.parse!(args)
rescue OptionParser::InvalidOption => e
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
puts parser
exit 1
end
puts "#{Color::CYAN}📋 Generando reporte del plan ADN BITACORAs...#{Color::RESET}"
puts " #{Color::DIM}Formato: #{opts[:formato]}#{Color::RESET}"
puts " #{Color::DIM}Archivo: #{@plan_path}#{Color::RESET}"
puts ""
begin
parser_obj = Plan::PlanParser.new(@plan_path, @logger)
parser_obj.parse_file
reporte = parser_obj.progress_report(format: opts[:formato].to_sym)
if opts[:output]
File.write(opts[:output], reporte, encoding: 'UTF-8')
puts "#{Color::GREEN}✅ Reporte guardado en: #{opts[:output]}#{Color::RESET}"
puts " #{Color::DIM}Tamaño: #{reporte.bytesize} bytes#{Color::RESET}"
else
puts reporte
end
rescue => e
puts "#{Color::RED}✗ Error generando reporte: #{e.message}#{Color::RESET}"
exit 1
end
end
# Comando: validar - Validar integridad del plan
def comando_validar(args)
opts = { reparar: false }
parser = OptionParser.new do |opt|
opt.banner = "Uso: adn plan validar [OPCIONES]"
opt.on("--reparar", "Intentar reparar problemas automáticamente") do
opts[:reparar] = true
end
opt.on("--plan RUTA", "Ruta al archivo de plan") do |ruta|
@plan_path = File.expand_path(ruta)
end
opt.on("-h", "--help", "Mostrar ayuda") do
puts opt
exit
end
end
begin
parser.parse!(args)
rescue OptionParser::InvalidOption => e
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
puts parser
exit 1
end
puts "#{Color::CYAN}🔍 Validando integridad del plan ADN BITACORAs...#{Color::RESET}"
puts " #{Color::DIM}Archivo: #{@plan_path}#{Color::RESET}"
puts " #{Color::DIM}Reparar: #{opts[:reparar]}#{Color::RESET}"
puts ""
begin
parser_obj = Plan::PlanParser.new(@plan_path, @logger)
parser_obj.parse_file
resultados = parser_obj.validate_plan
if resultados[:valid]
puts "#{Color::GREEN}✅ Plan válido#{Color::RESET}"
puts "#{resultados[:tasks_count]} tareas detectadas"
puts "#{resultados[:phases_count]} fases detectadas"
else
puts "#{Color::RED}✗ Plan contiene errores:#{Color::RESET}"
resultados[:errors].each do |error|
puts "#{error}"
end
end
if resultados[:warnings].any?
puts ""
puts "#{Color::YELLOW}⚠ Advertencias encontradas:#{Color::RESET}"
resultados[:warnings].each do |warning|
puts "#{warning}"
end
end
if opts[:reparar] && (!resultados[:valid] || resultados[:warnings].any?)
puts ""
puts "#{Color::CYAN}🔄 Intentando reparar automáticamente...#{Color::RESET}"
# Aquí se podrían implementar reparaciones automáticas
puts " #{Color::DIM}(Reparación automática no implementada aún)#{Color::RESET}"
end
rescue => e
puts "#{Color::RED}✗ Error validando el plan: #{e.message}#{Color::RESET}"
exit 1
end
end
# Comando: exportar - Exportar datos de progreso
def comando_exportar(args)
opts = { formato: 'json', output: nil }
parser = OptionParser.new do |opt|
opt.banner = "Uso: adn plan exportar [OPCIONES]"
opt.on("--formato FORMATO", "Formato de exportación (json, yaml)") do |f|
opts[:formato] = f.downcase
end
opt.on("--output ARCHIVO", "Archivo de salida (default: progreso_YYYYMMDD.json)") do |archivo|
opts[:output] = archivo
end
opt.on("--plan RUTA", "Ruta al archivo de plan") do |ruta|
@plan_path = File.expand_path(ruta)
end
opt.on("-h", "--help", "Mostrar ayuda") do
puts opt
exit
end
end
begin
parser.parse!(args)
rescue OptionParser::InvalidOption => e
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
puts parser
exit 1
end
puts "#{Color::CYAN}💾 Exportando datos de progreso del plan...#{Color::RESET}"
puts " #{Color::DIM}Formato: #{opts[:formato]}#{Color::RESET}"
puts " #{Color::DIM}Archivo: #{@plan_path}#{Color::RESET}"
puts ""
begin
parser_obj = Plan::PlanParser.new(@plan_path, @logger)
parser_obj.parse_file
if opts[:formato] == 'yaml'
datos = {
metadata: parser_obj.instance_variable_get(:@metadata),
progress: parser_obj.progress_stats,
tasks: parser_obj.instance_variable_get(:@tasks).map { |t| t.reject { |k| k == :line_number } },
phases: parser_obj.instance_variable_get(:@phases)
}
contenido = YAML.dump(datos)
extension = '.yaml'
else
contenido = parser_obj.export_progress
extension = '.json'
end
output_file = opts[:output] || "progreso_#{Time.now.strftime('%Y%m%d')}#{extension}"
File.write(output_file, contenido, encoding: 'UTF-8')
puts "#{Color::GREEN}✅ Datos exportados exitosamente:#{Color::RESET}"
puts " • Archivo: #{output_file}"
puts " • Tamaño: #{contenido.bytesize} bytes"
puts " • Tareas: #{parser_obj.instance_variable_get(:@tasks).size}"
puts " • Fases: #{parser_obj.instance_variable_get(:@phases).size}"
rescue => e
puts "#{Color::RED}✗ Error exportando datos: #{e.message}#{Color::RESET}"
exit 1
end
end
# Muestra la ayuda general del subcomando
def mostrar_ayuda
puts "#{Color::BOLD}Uso: ruby tools/adn.rb plan [SUBCOMANDO] [OPCIONES]#{Color::RESET}"
puts ""
puts "#{Color::BOLD}Subcomandos disponibles:#{Color::RESET}"
puts ""
puts " #{Color::CYAN}progreso [OPCIONES]#{Color::RESET} Mostrar progreso actual del plan"
puts " #{Color::CYAN}actualizar TAREAS [OPCIONES]#{Color::RESET} Marcar tareas como completadas"
puts " #{Color::CYAN}reporte [OPCIONES]#{Color::RESET} Generar reporte detallado"
puts " #{Color::CYAN}validar [OPCIONES]#{Color::RESET} Validar integridad del plan"
puts " #{Color::CYAN}exportar [OPCIONES]#{Color::RESET} Exportar datos de progreso"
puts " #{Color::CYAN}ayuda#{Color::RESET} Mostrar esta ayuda"
puts ""
puts "#{Color::BOLD}Opciones comunes:#{Color::RESET}"
puts " #{Color::DIM}--plan RUTA#{Color::RESET} Ruta al archivo de plan"
puts " #{Color::DIM}--formato FORMATO#{Color::RESET} Formato de salida (texto, json, markdown, yaml)"
puts " #{Color::DIM}--output ARCHIVO#{Color::RESET} Guardar salida en archivo"
puts ""
puts "#{Color::BOLD}Ejemplos:#{Color::RESET}"
puts " #{Color::DIM}ruby tools/adn.rb plan progreso --detallado#{Color::RESET}"
puts " #{Color::DIM}ruby tools/adn.rb plan actualizar 1.1 1.2 2.3 --notas \"Implementado\"#{Color::RESET}"
puts " #{Color::DIM}ruby tools/adn.rb plan reporte --formato markdown --output reporte.md#{Color::RESET}"
puts " #{Color::DIM}ruby tools/adn.rb plan validar --reparar#{Color::RESET}"
puts " #{Color::DIM}ruby tools/adn.rb plan exportar --formato json --output progreso.json#{Color::RESET}"
puts ""
puts "#{Color::YELLOW}⚠ Archivo de plan por defecto:#{Color::RESET} #{File.basename(DEFAULT_PLAN_PATH)}"
puts " #{Color::DIM}#{DEFAULT_PLAN_PATH}#{Color::RESET}"
end
end
end
+276
View File
@@ -0,0 +1,276 @@
# frozen_string_literal: true
# tools/adn/subcomando_sync.rb — Subcomando para sincronizar archivos MD con API dtic-BITACORAs
# =============================================================================================
# Este subcomando permite sincronizar bitácoras en formato Markdown con la base de datos
# de dtic-BITACORAs mediante la API REST.
require_relative 'clients/dtic_bitacoras_api'
require 'optparse'
require 'fileutils'
module ADN
class SubcomandoSync
def initialize(args, logger)
@args = args
@logger = logger
@opciones = {
sobrescribir: false,
validar_primero: true,
batch: false,
fecha: nil,
ruta: nil
}
@resultados = {
archivos_procesados: 0,
archivos_exitosos: 0,
archivos_fallidos: 0,
entradas_sincronizadas: 0,
errores: []
}
end
def ejecutar
parser = OptionParser.new do |opts|
opts.banner = "Uso: ruby tools/adn.rb sync [OPCIONES] [RUTA_O_FECHA]"
opts.separator ""
opts.separator "Sincroniza archivos Markdown con la API de dtic-BITACORAs"
opts.separator ""
opts.separator "Opciones:"
opts.on("--sobrescribir", "Sobrescribir entradas existentes en la base de datos") do
@opciones[:sobrescribir] = true
end
opts.on("--no-validar", "No validar archivos antes de sincronizar") do
@opciones[:validar_primero] = false
end
opts.on("--batch", "Sincronizar todos los archivos en el directorio bitacoras/") do
@opciones[:batch] = true
end
opts.on("--fecha FECHA", "Sincronizar bitácora específica (formato: YYYY-MM-DD)") do |f|
@opciones[:fecha] = f
end
opts.on("-h", "--help", "Mostrar esta ayuda") do
puts opts
exit
end
end
begin
parser.parse!(@args)
rescue OptionParser::InvalidOption => e
@logger.error("Opción inválida: #{e.message}")
puts parser
exit 1
end
# Determinar qué sincronizar
if @args.any? && !@opciones[:batch] && !@opciones[:fecha]
@opciones[:ruta] = @args[0]
end
ejecutar_sincronizacion
end
private
def ejecutar_sincronizacion
@logger.info("Iniciando sincronización con API dtic-BITACORAs", @opciones)
# Inicializar cliente API
begin
@client = DticBitacorasApi::Client.new(logger: @logger)
salud = @client.salud
@logger.exito("API dtic-BITACORAs disponible: #{salud['status']}")
rescue DticBitacorasApi::ApiError => e
@logger.error("No se puede conectar a la API: #{e.message}")
mostrar_conexion_fallback
exit 1
rescue DticBitacorasApi::ConfigError => e
@logger.error("Error de configuración: #{e.message}")
puts "Configura la URL de la API en tools/adn/config.yml o con export DTIC_API_URL=http://localhost:3002"
exit 1
end
# Determinar archivos a sincronizar
archivos = determinar_archivos_a_sincronizar
if archivos.empty?
@logger.advertencia("No se encontraron archivos para sincronizar")
mostrar_estadisticas
exit 0
end
puts "\n#{Color::CYAN}🔄 Sincronizando #{archivos.size} archivo(s) con dtic-BITACORAs#{Color::RESET}"
puts "#{Color::DIM}#{'-' * 60}#{Color::RESET}"
# Procesar cada archivo
archivos.each_with_index do |archivo, index|
procesar_archivo(archivo, index + 1, archivos.size)
end
mostrar_estadisticas
end
def determinar_archivos_a_sincronizar
archivos = []
if @opciones[:batch]
# Sincronizar todos los archivos en bitacoras/
patron = File.join(ADN::BITACORAS_DIR, "*.md")
archivos = Dir.glob(patron).sort
elsif @opciones[:fecha]
# Sincronizar archivo específico por fecha
ruta = File.join(ADN::BITACORAS_DIR, "#{@opciones[:fecha]}.md")
archivos = File.exist?(ruta) ? [ruta] : []
elsif @opciones[:ruta]
# Sincronizar archivo específico por ruta
archivos = [File.expand_path(@opciones[:ruta])]
else
# Sincronizar archivo del día actual
fecha_hoy = Date.today.strftime('%Y-%m-%d')
ruta_hoy = File.join(ADN::BITACORAS_DIR, "#{fecha_hoy}.md")
archivos = File.exist?(ruta_hoy) ? [ruta_hoy] : []
end
archivos
end
def procesar_archivo(ruta_archivo, indice, total)
nombre = File.basename(ruta_archivo)
puts "\n#{Color::CYAN}[#{indice}/#{total}] #{nombre}#{Color::RESET}"
unless File.exist?(ruta_archivo)
@resultados[:errores] << "#{nombre}: Archivo no encontrado"
@resultados[:archivos_fallidos] += 1
puts " #{Color::RED}✗ Archivo no encontrado#{Color::RESET}"
return
end
# Extraer fecha del nombre del archivo
fecha_match = nombre.match(/(\d{4}-\d{2}-\d{2})\.md/)
unless fecha_match
@resultados[:errores] << "#{nombre}: Formato de nombre inválido (debe ser YYYY-MM-DD.md)"
@resultados[:archivos_fallidos] += 1
puts " #{Color::RED}✗ Formato de nombre inválido#{Color::RESET}"
return
end
fecha = fecha_match[1]
# Validar formato del archivo si está habilitado
if @opciones[:validar_primero]
unless validar_archivo(ruta_archivo)
@resultados[:errores] << "#{nombre}: Falló validación previa"
@resultados[:archivos_fallidos] += 1
return
end
end
# Sincronizar con la API
begin
resultado = @client.sincronizar_md(ruta_archivo, sobrescribir: @opciones[:sobrescribir])
@resultados[:archivos_procesados] += 1
@resultados[:archivos_exitosos] += 1
@resultados[:entradas_sincronizadas] += resultado[:entradas_procesadas]
puts " #{Color::GREEN}✓ Sincronizado exitosamente#{Color::RESET}"
puts " #{Color::DIM}Bitácora ID: #{resultado[:bitacora_id]}#{Color::RESET}"
puts " #{Color::DIM}Entradas: #{resultado[:entradas_procesadas]}#{Color::RESET}"
@logger.info("Archivo sincronizado: #{nombre}", resultado)
rescue DticBitacorasApi::ApiError => e
@resultados[:archivos_procesados] += 1
@resultados[:archivos_fallidos] += 1
@resultados[:errores] << "#{nombre}: #{e.message}"
puts " #{Color::RED}✗ Error de API: #{e.message}#{Color::RESET}"
@logger.error("Error sincronizando #{nombre}: #{e.message}")
rescue => e
@resultados[:archivos_procesados] += 1
@resultados[:archivos_fallidos] += 1
@resultados[:errores] << "#{nombre}: Error inesperado - #{e.message}"
puts " #{Color::RED}✗ Error inesperado: #{e.message}#{Color::RESET}"
@logger.error("Error inesperado en #{nombre}: #{e.message}", backtrace: e.backtrace)
end
end
def validar_archivo(ruta_archivo)
puts " #{Color::CYAN}🔍 Validando formato...#{Color::RESET}"
contenido = File.read(ruta_archivo, encoding: 'UTF-8')
errores_validacion = []
# Validar formato básico
unless contenido.include?('## 📝 Actividades Detalladas')
errores_validacion << "Falta sección '## 📝 Actividades Detalladas'"
end
# Validar que tenga al menos una tabla de entradas
unless contenido.match(/\|\s*[I]\s*\|\s*[F]\s*\|\s*Descripción\s*\|\s*[E]\s*\|/)
errores_validacion << "No se encontró tabla de entradas I-F-D-E"
end
# Validar formato de tiempo
if contenido.match(/\d{1,2}:\d{2}\s*hs/)
errores_validacion << "Formato de tiempo incorrecto (contiene 'hs')"
end
if errores_validacion.empty?
puts " #{Color::GREEN}✓ Validación exitosa#{Color::RESET}"
true
else
puts " #{Color::YELLOW}⚠ Advertencias de validación:#{Color::RESET}"
errores_validacion.each { |e| puts " - #{e}" }
puts " #{Color::DIM}Continuando con sincronización...#{Color::RESET}"
true # Continuar a pesar de advertencias
end
end
def mostrar_estadisticas
puts "\n#{Color::CYAN}📊 Resultados de sincronización#{Color::RESET}"
puts "#{Color::DIM}#{'-' * 60}#{Color::RESET}"
puts " #{Color::GREEN}Archivos procesados:#{Color::RESET} #{@resultados[:archivos_procesados]}"
puts " #{Color::GREEN}Archivos exitosos:#{Color::RESET} #{@resultados[:archivos_exitosos]}"
if @resultados[:archivos_fallidos] > 0
puts " #{Color::RED}Archivos fallidos:#{Color::RESET} #{@resultados[:archivos_fallidos]}"
else
puts " #{Color::GREEN}Archivos fallidos:#{Color::RESET} 0"
end
puts " #{Color::GREEN}Entradas sincronizadas:#{Color::RESET} #{@resultados[:entradas_sincronizadas]}"
# Mostrar estadísticas de la API
mostrar_estadisticas_api
# Mostrar errores si los hay
unless @resultados[:errores].empty?
puts "\n#{Color::YELLOW}⚠ Errores encontrados:#{Color::RESET}"
@resultados[:errores].each do |error|
puts " #{Color::RED}#{error}#{Color::RESET}"
end
end
# Resumen final
if @resultados[:archivos_fallidos] == 0
@logger.exito("Sincronización completada exitosamente")
puts "\n#{Color::GREEN}✅ Sincronización completada exitosamente#{Color::RESET}"
else
@logger.advertencia("Sincronización completada con errores")
puts "\n#{Color::YELLOW}⚠ Sincronización completada con #{@resultados[:archivos_fallidos]} error(es)#{Color::RESET}"
end
@logger.info("Resumen de sincronización", @resultados)
end
def mostrar_estadisticas_api
begin
stats = @client.estadisticas
puts "\n#{Color::CYAN}📈 Estadísticas de dtic-BITACORAs#{Color::RESET}"
puts "#{Color::DIM}#{'-' * 40}#{Color::RESET}"
puts " #{Color::GREEN}Nodos en sistema:#{Color::RESET} #{stats[:nodos]}"
puts " #{Color::GREEN}Bitácoras en sistema:#{Color::RESET}
+304
View File
@@ -0,0 +1,304 @@
# frozen_string_literal: true
# tools/adn/subcomando_triggers.rb — Subcomando para gestión del sistema de triggers ADN
# ================================================================================
# Este subcomando permite gestionar el sistema de triggers que implementa
# el Principio de Armonía Integral mediante la propagación automática de cambios.
#
# Integrado con: ADN::Triggers (tools/adn/triggers.rb)
# Configuración: adn/triggers.yml
#
# 📅 Última actualización: 2026-03-07 (Fase 2: Automatización Inteligente)
# 📚 Referencia: docs/plan/260307-1400_Plan_Mejoras_ADN.md
require 'optparse'
require_relative 'triggers'
require_relative 'eventos'
module ADN
class SubcomandoTriggers
def initialize(args, logger)
@args = args
@logger = logger
@opciones = {
test: false,
forzar: false,
listar: false,
registrar: false,
limpiar: false,
trigger_id: nil,
ayuda: false
}
end
# Ejecuta el subcomando con las opciones especificadas
def ejecutar
parser = crear_parser
begin
parser.parse!(@args)
rescue OptionParser::InvalidOption => e
@logger.error("Opción inválida: #{e.message}")
puts parser
exit 1
end
if @opciones[:ayuda] || @args.empty? && !@opciones[:listar] && !@opciones[:limpiar] && !@opciones[:registrar]
mostrar_ayuda(parser)
return
end
# Cargar configuración primero
cargar_configuracion
# Ejecutar acción según opciones
if @opciones[:listar]
listar_triggers
elsif @opciones[:registrar]
registrar_triggers
elsif @opciones[:limpiar]
limpiar_triggers
elsif @opciones[:test]
probar_trigger
else
ejecutar_triggers
end
end
private
# Crea el parser de opciones
def crear_parser
OptionParser.new do |opts|
opts.banner = "Uso: ruby tools/adn.rb triggers [OPCIONES]"
opts.separator ""
opts.separator "Gestiona el sistema de triggers para sincronización automática del ADN"
opts.separator ""
opts.separator "Opciones:"
opts.on("--test [TRIGGER_ID]", "Probar un trigger específico (modo simulación)") do |trigger_id|
@opciones[:test] = true
@opciones[:trigger_id] = trigger_id
end
opts.on("--forzar", "Ejecutar triggers incluso si sistema deshabilitado") do
@opciones[:forzar] = true
end
opts.on("--listar", "Listar todos los triggers configurados") do
@opciones[:listar] = true
end
opts.on("--registrar", "Registrar triggers en el bus de eventos") do
@opciones[:registrar] = true
end
opts.on("--limpiar", "Limpiar todos los triggers registrados") do
@opciones[:limpiar] = true
end
opts.on("-h", "--help", "Mostrar esta ayuda") do
@opciones[:ayuda] = true
end
end
end
# Muestra la ayuda del subcomando
def mostrar_ayuda(parser)
puts parser
puts ""
puts "Descripción:"
puts " El sistema de triggers implementa el Principio de Armonía Integral al propagar"
puts " automáticamente cambios entre diferentes componentes del sistema ADN."
puts ""
puts " Triggers definidos en: adn/triggers.yml"
puts " Bus de eventos: tools/adn/eventos.rb"
puts " Motor de triggers: tools/adn/triggers.rb"
puts ""
puts "Ejemplos:"
puts " ruby tools/adn.rb triggers --listar"
puts " ruby tools/adn.rb triggers --registrar"
puts " ruby tools/adn.rb triggers --test bitacoras_0"
puts " ruby tools/adn.rb triggers --forzar"
puts " ruby tools/adn.rb triggers --limpiar"
puts ""
puts "📚 Documentación: docs/plan/260307-1400_Plan_Mejoras_ADN.md"
end
# Carga la configuración de triggers
def cargar_configuracion
@logger.info("Cargando configuración de triggers...")
begin
@triggers = ADN::Triggers.instance
@triggers.cargar_configuracion
@logger.exito("Configuración de triggers cargada")
rescue => e
@logger.error("Error cargando configuración: #{e.message}")
exit 1
end
end
# Lista todos los triggers configurados
def listar_triggers
@logger.info("Listando triggers configurados...")
triggers = @triggers.lista_triggers
if triggers.empty?
puts "#{Color::YELLOW} No se encontraron triggers configurados#{Color::RESET}"
return
end
puts "#{Color::CYAN}📋 Triggers configurados (#{triggers.length} total)#{Color::RESET}"
puts "#{Color::DIM}#{'-' * 80}#{Color::RESET}"
triggers.each do |trigger|
estado = trigger[:habilitado] ? "#{Color::GREEN}#{Color::RESET}" : "#{Color::DIM}#{Color::RESET}"
suscriptores = trigger[:suscriptores_registrados] > 0 ?
"#{Color::CYAN}(#{trigger[:suscriptores_registrados]} susc.)#{Color::RESET}" : ""
puts " #{estado} #{trigger[:id]} #{suscriptores}"
puts " #{Color::DIM}Evento:#{Color::RESET} #{trigger[:evento]}"
puts " #{Color::DIM}Acción:#{Color::RESET} #{trigger[:accion]}"
if trigger[:condicion] && !trigger[:condicion].empty?
puts " #{Color::DIM}Condición:#{Color::RESET} #{trigger[:condicion]}"
end
puts " #{Color::DIM}Descripción:#{Color::RESET} #{trigger[:descripcion]}"
puts ""
end
habilitados = triggers.count { |t| t[:habilitado] }
puts "#{Color::DIM}Triggers habilitados: #{habilitados}/#{triggers.length}#{Color::RESET}"
@logger.info("Listado completado: #{triggers.length} triggers encontrados")
end
# Registra triggers en el bus de eventos
def registrar_triggers
@logger.info("Registrando triggers en el bus de eventos...")
registrados = @triggers.registrar_triggers
if registrados > 0
@logger.exito("#{registrados} trigger(s) registrado(s) en el bus de eventos")
puts "#{Color::GREEN}#{registrados} trigger(s) registrado(s)#{Color::RESET}"
puts "#{Color::DIM}Los triggers responderán a eventos automáticamente#{Color::RESET}"
else
@logger.advertencia("No se registraron triggers (sistema deshabilitado o sin triggers habilitados)")
puts "#{Color::YELLOW}⚠ No se registraron triggers#{Color::RESET}"
puts "#{Color::DIM}Verifica que el sistema esté habilitado en adn/triggers.yml#{Color::RESET}"
end
end
# Limpia todos los triggers registrados
def limpiar_triggers
@logger.info("Limpiando triggers registrados...")
limpiados = @triggers.limpiar_triggers_registrados
@logger.exito("#{limpiados} trigger(s) limpiado(s) del bus de eventos")
puts "#{Color::GREEN}#{limpiados} trigger(s) limpiado(s)#{Color::RESET}"
puts "#{Color::DIM}Los triggers ya no responderán a eventos automáticamente#{Color::RESET}"
end
# Prueba un trigger específico
def probar_trigger
trigger_id = @opciones[:trigger_id]
unless trigger_id
@logger.error("Se requiere ID de trigger para probar (ej: bitacoras_0)")
puts "#{Color::RED}✗ Se requiere ID de trigger para probar#{Color::RESET}"
puts " Ejemplo: ruby tools/adn.rb triggers --test bitacoras_0"
puts " Usa --listar para ver IDs disponibles"
exit 1
end
@logger.info("Probando trigger: #{trigger_id}")
# Datos simulados para la prueba
datos_simulados = {
modo: 'prueba',
timestamp: Time.now.utc,
descripcion: "Datos simulados para prueba del trigger"
}
# Ejecutar prueba
resultado = @triggers.probar_trigger(trigger_id, datos_simulados)
# Mostrar resultados
mostrar_resultado_prueba(resultado)
end
# Ejecuta todos los triggers (modo batch)
def ejecutar_triggers
@logger.info("Ejecutando triggers en modo batch...")
resultados = @triggers.ejecutar_triggers(forzar: @opciones[:forzar])
mostrar_resultados_ejecucion(resultados)
end
# Muestra el resultado de una prueba de trigger
def mostrar_resultado_prueba(resultado)
puts "\n#{Color::CYAN}🧪 Resultado de prueba del trigger#{Color::RESET}"
puts "#{Color::DIM}#{'-' * 60}#{Color::RESET}"
if resultado[:exito]
puts " #{Color::GREEN}✓ Trigger probado exitosamente#{Color::RESET}"
puts " #{Color::DIM}ID:#{Color::RESET} #{resultado[:trigger]}"
puts " #{Color::DIM}Modo:#{Color::RESET} #{resultado[:accion_simulada][:modo]}"
puts " #{Color::DIM}Mensaje:#{Color::RESET} #{resultado[:accion_simulada][:mensaje]}"
else
puts " #{Color::RED}✗ Error probando trigger#{Color::RESET}"
puts " #{Color::DIM}ID:#{Color::RESET} #{resultado[:trigger]}"
puts " #{Color::DIM}Error:#{Color::RESET} #{resultado[:error]}"
end
puts "#{Color::DIM}#{'-' * 60}#{Color::RESET}"
puts "#{Color::DIM}Nota: Esta fue una prueba en modo simulación (sin efectos reales)#{Color::RESET}"
@logger.info("Prueba completada: #{resultado[:exito] ? 'éxito' : 'fallo'}")
end
# Muestra resultados de ejecución de triggers
def mostrar_resultados_ejecucion(resultados)
total = resultados[:total_ejecutados]
exitosos = resultados[:exitosos]
fallidos = resultados[:fallidos]
puts "\n#{Color::CYAN}📊 Resultados de ejecución de triggers#{Color::RESET}"
puts "#{Color::DIM}#{'-' * 60}#{Color::RESET}"
puts " #{Color::GREEN}Ejecutados:#{Color::RESET} #{total}"
puts " #{Color::GREEN}Exitosos:#{Color::RESET} #{exitosos}"
puts " #{Color::GREEN}Fallidos:#{Color::RESET} #{fallidos}"
if fallidos > 0
puts "\n#{Color::YELLOW}⚠ Triggers con errores:#{Color::RESET}"
resultados[:detalles].each do |detalle|
next if detalle[:exito]
puts " #{Color::RED}#{detalle[:trigger_id]}#{Color::RESET}"
puts " #{Color::DIM}Evento:#{Color::RESET} #{detalle[:evento]}"
puts " #{Color::DIM}Error:#{Color::RESET} #{detalle[:error]}"
end
end
puts "#{Color::DIM}#{'-' * 60}#{Color::RESET}"
if total == 0
@logger.advertencia("No se ejecutaron triggers (sistema deshabilitado)")
puts "#{Color::YELLOW} No se ejecutaron triggers#{Color::RESET}"
puts "#{Color::DIM}Usa --forzar para ejecutar de todos modos#{Color::RESET}"
elsif fallidos == 0
@logger.exito("Ejecución completada: #{exitosos}/#{total} triggers exitosos")
puts "#{Color::GREEN}✓ Ejecución completada sin errores#{Color::RESET}"
else
@logger.error("Ejecución completada con errores: #{fallidos}/#{total} fallidos")
puts "#{Color::RED}✗ Ejecución completada con #{fallidos} error(es)#{Color::RESET}"
end
end
end
end
+525
View File
@@ -0,0 +1,525 @@
# frozen_string_literal: true
# tools/adn/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: tools/adn/eventos.rb (bus de eventos)
require 'yaml'
require 'singleton'
require_relative '../adn'
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.
+492
View File
@@ -0,0 +1,492 @@
# 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.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