Files

685 lines
23 KiB
Ruby

# frozen_string_literal: true
# adn/tools/parsers/plan.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