# 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] 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] 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] 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