Reestructuración modular integral de servicios, reorganización de gestión en docs/ y limpieza profunda del repositorio
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
---
|
||||
BUNDLE_PATH: "vendor/bundle"
|
||||
BUNDLE_WITHOUT: "development:test:future"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
#
|
||||
Executable
+467
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user