diff --git a/bin/cocomo.py b/bin/cocomo.py new file mode 100755 index 0000000..b866759 --- /dev/null +++ b/bin/cocomo.py @@ -0,0 +1,330 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +COCOMO Calculator + +Este script calcula el costo de un proyecto de software utilizando el modelo COCOMO, +basado en el conteo de líneas de código en un directorio dado. + +Licencia: AGPL-3.0 +Modified: 2025-03-12 +""" + +import os +import sys +import argparse +import logging +import fnmatch +from pathlib import Path +from math import ceil + +# Configuración de logging +console_handler = logging.StreamHandler() +console_handler.setLevel(logging.WARNING) +console_handler.setFormatter(logging.Formatter('%(levelname)s: %(message)s')) + +# Configuración global del logger +logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', handlers=[]) +logger = logging.getLogger('cocomo') +logger.addHandler(console_handler) + +# Variable para controlar si se muestra el resultado en la consola +SHOW_RESULTS = True + +# Constantes para el modelo COCOMO básico +COCOMO_MODELS = { + # [a, b, c, d] - Coeficientes para Esfuerzo = a * (KLOC^b) y Tiempo = c * (Esfuerzo^d) + 'organic': [2.4, 1.05, 2.5, 0.38], # Proyectos simples, pequeños equipos con experiencia + 'semi-detached': [3.0, 1.12, 2.5, 0.35], # Proyectos intermedios + 'embedded': [3.6, 1.20, 2.5, 0.32] # Proyectos complejos +} + +# Extensiones de archivo a considerar por defecto +DEFAULT_EXTENSIONS = [ + '.py', '.java', '.c', '.cpp', '.cc', '.cxx', '.h', '.hpp', '.js', '.ts', + '.html', '.css', '.php', '.rb', '.go', '.rs', '.swift', '.kt', '.scala', + '.sh', '.bash', '.pl', '.pm', '.lua', '.sql' +] + +# Archivos a ignorar por defecto +DEFAULT_IGNORE_PATTERNS = [ + '*.min.js', '*.min.css', '**/node_modules/**', '**/venv/**', '**/.git/**', + '**/__pycache__/**', '**/build/**', '**/dist/**', '**/bin/config/rates/**', + '**/bin/msg/**' +] + +def count_lines_of_code(project_path, extensions=None, ignore_patterns=None): + """ + Cuenta las líneas de código no vacías en los archivos con las extensiones especificadas. + Excluye comentarios en archivos Python. + + Args: + project_path: Ruta del directorio del proyecto + extensions: Lista de extensiones de archivo a considerar + ignore_patterns: Lista de patrones de archivo a ignorar + + Returns: + Número total de líneas de código + """ + if extensions is None: + extensions = DEFAULT_EXTENSIONS + + if ignore_patterns is None: + ignore_patterns = DEFAULT_IGNORE_PATTERNS + + total_lines = 0 + files_counted = 0 + skipped_files = 0 + + logger.info(f"Contando líneas de código en: {project_path}") + logger.info(f"Extensiones: {extensions}") + + for root, _, files in os.walk(project_path): + for file in files: + file_path = os.path.join(root, file) + rel_path = os.path.relpath(file_path, project_path) + + # Verificar si el archivo debe ser ignorado + should_ignore = False + for pattern in ignore_patterns: + if fnmatch.fnmatch(rel_path, pattern): + should_ignore = True + break + + if should_ignore: + logger.debug(f"Ignorando archivo: {rel_path}") + skipped_files += 1 + continue + + # Verificar si la extensión está en la lista + file_extension = os.path.splitext(file)[1].lower() + if file_extension not in extensions: + logger.debug(f"Extensión no considerada: {rel_path}") + skipped_files += 1 + continue + + try: + with open(file_path, 'r', encoding='utf-8') as f: + lines = f.readlines() + + # Contar líneas no vacías (excluyendo comentarios en Python) + if file_extension == '.py': + non_empty_lines = 0 + in_multiline_comment = False + + for line in lines: + line = line.strip() + + # Saltar líneas vacías + if not line: + continue + + # Manejar comentarios multilinea + if '"""' in line or "'''" in line: + # Si ya estamos en un comentario multilinea, verificamos si termina + if in_multiline_comment: + if line.endswith('"""') or line.endswith("'''"): + in_multiline_comment = False + continue + # Si encontramos el inicio de un comentario multilinea + elif line.startswith('"""') or line.startswith("'''"): + # Verificar si el comentario comienza y termina en la misma línea + if line.endswith('"""') or line.endswith("'''"): + if line.count('"""') == 2 or line.count("'''") == 2: + continue + else: + in_multiline_comment = True + continue + + # Saltar si estamos en un comentario multilinea + if in_multiline_comment: + continue + + # Saltar comentarios de una línea + if line.startswith('#'): + continue + + # Contar línea de código válida + non_empty_lines += 1 + + total_lines += non_empty_lines + else: + # Para otros lenguajes, simplemente contamos líneas no vacías + non_empty_lines = sum(1 for line in lines if line.strip()) + total_lines += non_empty_lines + + files_counted += 1 + + if files_counted % 100 == 0: + logger.info(f"Procesados {files_counted} archivos, {total_lines} líneas contadas hasta ahora.") + + except Exception as e: + logger.warning(f"Error al procesar archivo {file_path}: {e}") + skipped_files += 1 + + logger.info(f"Conteo completo. Procesados {files_counted} archivos, ignorados {skipped_files}.") + return total_lines + +def estimate_cocomo(loc, model_type='organic', cost_per_pm=5000): + """ + Calcula las estimaciones COCOMO basadas en las líneas de código. + + Args: + loc: Líneas de código + model_type: Tipo de modelo COCOMO ('organic', 'semi-detached', 'embedded') + cost_per_pm: Costo por persona-mes en USD + + Returns: + Dictionary con los resultados del cálculo + """ + # Convertir LOC a KLOC (miles de líneas de código) + kloc = loc / 1000 + + # Obtener coeficientes del modelo + a, b, c, d = COCOMO_MODELS[model_type] + + # Calcular esfuerzo en persona-mes + effort_pm = a * (kloc ** b) + + # Calcular tiempo de desarrollo en meses + dev_time = c * (effort_pm ** d) + + # Calcular personas requeridas (promedio) + avg_staff = effort_pm / dev_time + + # Calcular costo total + total_cost = effort_pm * cost_per_pm + + # Redondear valores para presentación + effort_pm = round(effort_pm, 2) + dev_time = round(dev_time, 2) + avg_staff = round(avg_staff, 2) + total_cost = round(total_cost, 2) + + # Preparar resultados + results = { + 'loc': loc, + 'kloc': round(kloc, 2), + 'model': model_type, + 'effort_pm': effort_pm, + 'dev_time': dev_time, + 'avg_staff': avg_staff, + 'cost_per_pm': cost_per_pm, + 'total_cost': total_cost + } + + return results + +def show_result(message): + """ + Función para mostrar resultados en la consola. + Solo muestra mensajes si SHOW_RESULTS es True. + """ + if SHOW_RESULTS: + print(message) + +def determine_cocomo_model(loc): + """ + Determina automáticamente el modelo COCOMO basado en el tamaño del proyecto. + + Args: + loc: Líneas de código + + Returns: + Tipo de modelo COCOMO ('organic', 'semi-detached', 'embedded') + """ + kloc = loc / 1000 + + if kloc < 50: + return 'organic' # Proyectos pequeños (menos de 50K líneas) + elif kloc < 300: + return 'semi-detached' # Proyectos medianos (entre 50K y 300K líneas) + else: + return 'embedded' # Proyectos grandes (más de 300K líneas) + +def print_results(results): + """ + Imprime los resultados del cálculo COCOMO de manera formateada. + + Args: + results: Dictionary con los resultados del cálculo COCOMO + """ + print("\n========== RESULTADOS DEL ANÁLISIS COCOMO ==========") + print(f"Proyecto: {results['project_path']}") + print(f"Líneas de código: {results['loc']} ({results['kloc']} KLOC)") + print(f"Modelo COCOMO: {results['model'].upper()}") + print("\n--- Estimaciones ---") + print(f"Esfuerzo: {results['effort_pm']} persona-meses") + print(f"Tiempo de desarrollo: {results['dev_time']} meses") + print(f"Personal promedio: {results['avg_staff']} personas") + print("\n--- Costos ---") + print(f"Costo por persona-mes: ${results['cost_per_pm']}") + print(f"Costo total estimado: ${results['total_cost']:,.2f}") + print("=====================================================\n") + +if __name__ == "__main__": + # Parámetros de línea de comandos + parser = argparse.ArgumentParser(description='Calculadora COCOMO para estimar costos de proyectos de software') + parser.add_argument('--project', required=True, help='Ruta del directorio del proyecto a analizar') + parser.add_argument('--model', choices=['organic', 'semi-detached', 'embedded', 'auto'], + default='auto', help='Tipo de modelo COCOMO a utilizar (por defecto: auto)') + parser.add_argument('--cost', type=float, default=5000.0, + help='Costo por persona-mes en USD (por defecto: 5000)') + parser.add_argument('--ignore', action='append', + help='Patrones adicionales de archivos a ignorar') + parser.add_argument('--ext', action='append', + help='Extensiones adicionales de archivo a considerar') + parser.add_argument('-q', '--quiet', action='store_true', + help='No mostrar resultados detallados') + parser.add_argument('-v', '--verbose', action='store_true', + help='Mostrar información detallada del proceso') + + args = parser.parse_args() + + # Configurar nivel de log según parámetros + if args.verbose: + console_handler.setLevel(logging.INFO) + + # Configurar si se muestran resultados + SHOW_RESULTS = not args.quiet + + # Verificar que el directorio del proyecto existe + project_path = Path(args.project) + if not project_path.exists() or not project_path.is_dir(): + logger.error(f"El directorio del proyecto no existe: {args.project}") + print(f"ERROR: El directorio del proyecto no existe: {args.project}") + sys.exit(1) + + # Preparar extensiones y patrones de ignorar + extensions = DEFAULT_EXTENSIONS.copy() + if args.ext: + extensions.extend([ext if ext.startswith('.') else f'.{ext}' for ext in args.ext]) + + ignore_patterns = DEFAULT_IGNORE_PATTERNS.copy() + if args.ignore: + ignore_patterns.extend(args.ignore) + + try: + # Contar líneas de código + loc = count_lines_of_code(project_path, extensions, ignore_patterns) + + # Determinar el modelo COCOMO a utilizar + if args.model == 'auto': + model_type = determine_cocomo_model(loc) + logger.info(f"Modelo seleccionado automáticamente: {model_type}") + else: + model_type = args.model + + # Calcular estimaciones COCOMO + results = estimate_cocomo(loc, model_type, args.cost) + + # Añadir ruta del proyecto para la presentación + results['project_path'] = str(project_path) + + # Mostrar resultados + print_results(results) + + except Exception as e: + logger.error(f"Error durante el cálculo COCOMO: {e}") + print(f"ERROR: {e}") + sys.exit(1) \ No newline at end of file