No se pudo generar esta sección: ' . $e->getMessage(); } }, $md); } private static function pdo(): PDO { return Database::getInstance()->getConnection(); } private static function raiz(): string { return dirname(__DIR__, 2); } // ── Módulos registrados ─────────────────────────────────────────── private static function genModulos(): string { $dirs = glob(self::raiz() . '/modules/*', GLOB_ONLYDIR) ?: []; $filas = []; foreach ($dirs as $dir) { $slug = basename($dir); $meta = ['name' => $slug, 'description' => '', 'oleada' => '']; $mf = $dir . '/module.php'; if (is_file($mf)) { try { $m = @include $mf; if (is_array($m)) $meta = $m + $meta; } catch (\Throwable $e) { /* un module.php con contexto no cargable no debe romper la doc */ } } $vistas = count(glob($dir . '/views/*.php') ?: []); $apis = count(glob($dir . '/api/*.php') ?: []); $enSys = defined('SYSTEM_MODULES') && array_key_exists($slug, SYSTEM_MODULES); $filas[] = [ '`' . $slug . '`', $meta['name'] ?? $slug, $vistas ?: '—', $apis ?: '—', $enSys ? 'sí' : 'no', $meta['description'] ?? '', ]; } usort($filas, fn($a, $b) => strcmp($a[0], $b[0])); return self::tabla( ['Slug', 'Nombre', 'Vistas', 'Endpoints', 'En SYSTEM_MODULES', 'Descripción'], $filas ) . "\n\n_" . count($filas) . " módulos en `modules/`. Generado del filesystem._\n"; } // ── Endpoints por módulo ────────────────────────────────────────── private static function genEndpoints(): string { $out = ''; foreach (glob(self::raiz() . '/modules/*/api', GLOB_ONLYDIR) ?: [] as $dir) { $slug = basename(dirname($dir)); $files = glob($dir . '/*.php') ?: []; if (!$files) continue; sort($files); $filas = []; foreach ($files as $f) { $nombre = basename($f); if (str_starts_with($nombre, '_')) continue; // helpers internos $filas[] = ['`' . $nombre . '`', self::resumenPhpDoc($f)]; } if (!$filas) continue; $out .= "\n### " . $slug . "\n\n" . self::tabla(['Endpoint', 'Qué hace'], $filas) . "\n"; } return $out ?: '> Sin endpoints.'; } // ── Tablas de la base de datos ──────────────────────────────────── private static function genTablas(): string { $rows = self::pdo()->query( "SELECT table_name, table_rows, table_comment FROM information_schema.tables WHERE table_schema = DATABASE() AND table_type = 'BASE TABLE' ORDER BY table_name" )->fetchAll(PDO::FETCH_ASSOC); // Agrupar por prefijo para que se lea por dominio $grupos = []; foreach ($rows as $r) { $t = $r['table_name']; $pfx = str_contains($t, '_') ? explode('_', $t)[0] : 'otras'; $grupos[$pfx][] = $r; } ksort($grupos); $out = ''; foreach ($grupos as $pfx => $tablas) { $out .= "\n### " . $pfx . "\n\n"; $filas = []; foreach ($tablas as $t) { $cols = self::pdo()->prepare( "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ?" ); $cols->execute([$t['table_name']]); $filas[] = [ '`' . $t['table_name'] . '`', (string)(int)$cols->fetchColumn(), number_format((int)$t['table_rows'], 0, ',', '.'), $t['table_comment'] ?: '', ]; } $out .= self::tabla(['Tabla', 'Columnas', 'Filas aprox.', 'Comentario'], $filas) . "\n"; } return $out . "\n_" . count($rows) . " tablas. Generado de `information_schema`; el conteo de filas es una estimación de InnoDB._\n"; } // ── Roles y permisos efectivos ──────────────────────────────────── private static function genRoles(): string { $roles = self::pdo()->query( "SELECT r.id, r.slug, r.name, r.description, (SELECT COUNT(*) FROM admin_users u WHERE u.role_id = r.id AND u.is_active = 1) AS usuarios FROM roles r ORDER BY r.slug" )->fetchAll(PDO::FETCH_ASSOC); $mods = self::pdo()->query( "SELECT role_id, module_slug, permission FROM role_modules ORDER BY module_slug" )->fetchAll(PDO::FETCH_ASSOC); $porRol = []; foreach ($mods as $m) { $porRol[(int)$m['role_id']][] = $m['module_slug'] . ($m['permission'] === 'read' ? ' _(solo lectura)_' : ''); } $filas = []; foreach ($roles as $r) { $lista = $porRol[(int)$r['id']] ?? []; $filas[] = [ '`' . $r['slug'] . '`', $r['name'] ?: '', (string)(int)$r['usuarios'], $lista ? implode(', ', $lista) : '—', ]; } return self::tabla(['Rol', 'Nombre', 'Usuarios activos', 'Módulos'], $filas) . "\n\n_Generado de `roles` y `role_modules`. El acceso efectivo se carga al iniciar sesión desde `role_id`._\n"; } // ── Servicios y clases del núcleo ───────────────────────────────── private static function genServicios(): string { $out = ''; foreach ([['core', 'Núcleo'], ['services', 'Servicios'], ['classes', 'Clases de dominio']] as [$dir, $tit]) { $files = glob(self::raiz() . '/' . $dir . '/*.php') ?: []; if (!$files) continue; sort($files); $filas = []; foreach ($files as $f) { $filas[] = ['`' . basename($f) . '`', self::resumenPhpDoc($f)]; } $out .= "\n### " . $tit . " (`" . $dir . "/`)\n\n" . self::tabla(['Archivo', 'Responsabilidad'], $filas) . "\n"; } return $out; } /** Primera línea con contenido del bloque docblock inicial de un archivo. */ private static function resumenPhpDoc(string $archivo): string { $fh = @fopen($archivo, 'r'); if (!$fh) return ''; $n = 0; $ruta = null; while (($l = fgets($fh)) !== false && $n++ < 25) { $l = trim($l); if (!str_starts_with($l, '*')) continue; $l = trim(ltrim($l, '*/ ')); if ($l === '') continue; // Saltar la línea que solo repite la ruta del archivo if ($ruta === null && str_contains($l, '.php')) { $ruta = $l; continue; } if (str_starts_with($l, '@')) break; fclose($fh); return $l; } fclose($fh); return ''; } /** Arma una tabla Markdown escapando los separadores del contenido. */ private static function tabla(array $encabezados, array $filas): string { $esc = fn($v) => str_replace('|', '\\|', (string)$v); $md = '| ' . implode(' | ', array_map($esc, $encabezados)) . " |\n"; $md .= '|' . str_repeat('---|', count($encabezados)) . "\n"; foreach ($filas as $f) { $md .= '| ' . implode(' | ', array_map($esc, $f)) . " |\n"; } return $md; } }