diff --git a/CHANGELOG.md b/CHANGELOG.md index 31819b5..5fe4c3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Formato basado en [Keep a Changelog](https://keepachangelog.com/) y [SemVer](htt - **Overlays de Spring, .NET y Angular más profundos.** Spring: virtual threads y `@Async` con executor explícito, Resilience4j, `@Cacheable` con clave y TTL, Actuator + Micrometer, `readOnly` en lecturas, la trampa del proxy de `@Transactional` en llamadas internas, y slices de test frente a `@SpringBootTest`. .NET: `CancellationToken` propagado, `IHttpClientFactory` con typed clients y resiliencia, `HybridCache`, OpenTelemetry y health checks, `MapGroup` con filtros, la captura de `Scoped` desde `Singleton`, `Nullable`/`TreatWarningsAsErrors`, y sustitución de dependencias vía `ConfigureTestServices`. Angular: `@defer`, `computed` frente a `effect`, `linkedSignal`, rutas lazy con guards funcionales y `withComponentInputBinding`, interceptores funcionales, formularios reactivos tipados, `NgOptimizedImage`, accesibilidad y presupuestos de bundle. ### Fixed +- **La detección solo miraba la raíz del repo.** En .NET el layout normal es `Proyecto/Proyecto.csproj` o `src/App/App.csproj`, así que un proyecto C# perfectamente estándar no se detectaba y había que pasar `--stack` a mano. Ahora, **si y solo si** en la raíz no hay nada reconocible, se buscan subdirectorios en anchura (hasta 3 niveles, máximo 250 directorios, saltando `node_modules`, `bin`, `obj`, `target`, `dist`, `packages`…). La raíz sigue teniendo prioridad absoluta, así que ningún proyecto que ya se detectaba cambia de resultado. El instalador informa de por dónde lo detectó (`backend/dotnet (por PruebaConteo/, no por la raíz)`) para que la decisión sea auditable. Coste medido: 156 ms en un repo de 422 directorios sin nada detectable. - **Detección de Spring incompleta**: se exigía la cadena literal `spring-boot` en el `pom.xml`, así que un proyecto que solo declara `org.springframework` (BOM o Spring Framework sin Boot) no se detectaba; y los `settings.gradle`/`settings.gradle.kts` no se miraban en absoluto. Ahora se buscan varias señales en los cinco archivos de build. - **Un proyecto Java sin Spring quedaba en un callejón sin salida**: la detección devolvía `null` sin explicar nada y, con `--yes`, el instalador salía con código 1 a secas. Ahora imprime qué vio (`pom.xml`/`build.gradle`), aclara que el overlay de Java del kit es Spring Boot y avisa de que para Quarkus, Micronaut o Jakarta EE puro ese overlay no encaja. Misma pista para ecosistemas sin overlay (Go, Rust) y para un `package.json` sin framework reconocido. - **`validate.cjs` verifica que el README no mienta.** El README se había desviado en silencio: listaba helpers borrados dos releases antes, 9 de los 13 stacks, "los 8 stacks" del CI y ejemplos pineados a `#v1.2.0`. Ahora se comprueban contra el repo los conteos de agentes, skills y stacks (los del árbol y los del CI), la lista de helpers, los stacks por categoría, el número de componentes descartados, y que todo ejemplo `#vX.Y.Z` apunte a la versión de `package.json`. Si una de esas frases desaparece, falla explícitamente en vez de dejar de comprobar: 38 → 49 comprobaciones. diff --git a/README.md b/README.md index 91046f8..c38510b 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ Flags: `--stack backend/nestjs` (fuerza stack) · `--yes` (no interactivo, conse El instalador: 1. **Detecta el stack** (pubspec→flutter, react-native, next, angular, nest, foundry, `.csproj`/`.sln`→dotnet, Spring, django, composer→php, fastapi, react, express). + - **La raíz manda, pero no es obligatoria.** Si en la raíz no hay nada reconocible, se buscan subdirectorios (hasta 3 niveles, saltando `node_modules`, `bin`, `obj`, `target`, `dist`…). Es lo normal en .NET (`Proyecto/Proyecto.csproj`, `src/App/App.csproj`) y frecuente en Java y en repos con `backend/`+`web/`. El instalador dice **por dónde** lo detectó y sigue instalando en el directorio donde lo corriste, para que el `CLAUDE.md` cubra todo el repo. En un monorepo con `packages/` no adivina: pide `--stack`. - **Java/Kotlin**: se reconoce Spring por sus señales (`spring-boot`, `org.springframework`, el plugin de Gradle o `io.spring.dependency-management`) en `pom.xml`, `build.gradle(.kts)` y `settings.gradle(.kts)`. Un proyecto Java **sin** Spring no se fuerza al overlay de Spring: el instalador lo dice y te deja elegir. - Si la detección falla, imprime **qué vio** en el directorio en vez de rendirse sin explicación. - En un proyecto **fullstack con un solo `package.json`** (p. ej. express + react juntos) gana el **frontend**; si el repo es principalmente API, fuerza con `--stack backend/express`. diff --git a/install.js b/install.js index db0cb81..ce1c628 100644 --- a/install.js +++ b/install.js @@ -160,28 +160,63 @@ function fileHas(p, re) { return false; } } -function hasFile(...names) { - return names.some((n) => fs.existsSync(path.join(CWD, n))); +function hasFileIn(dir, ...names) { + return names.some((n) => fs.existsSync(path.join(dir, n))); } -function dirHasExt(re) { +const hasFile = (...names) => hasFileIn(CWD, ...names); +function dirHasExt(re, dir = CWD) { try { - return fs.readdirSync(CWD).some((f) => re.test(f)); + return fs.readdirSync(dir).some((f) => re.test(f)); } catch (_) { return false; } } // Archivos de build de Java/Kotlin. Un proyecto Java NO es necesariamente Spring. const JAVA_BUILD_FILES = ["pom.xml", "build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"]; -const javaBuildFiles = () => JAVA_BUILD_FILES.filter((f) => fs.existsSync(path.join(CWD, f))); +const javaBuildFiles = (dir = CWD) => JAVA_BUILD_FILES.filter((f) => fs.existsSync(path.join(dir, f))); + +// Directorios que nunca contienen el proyecto: artefactos, dependencias y basura de build. +// `packages/` se salta a propósito: en un monorepo, elegir un paquete al azar sería peor que +// pedir --stack. +const SKIP_SCAN = new Set([ + "node_modules", "bin", "obj", "target", "dist", "build", "out", "vendor", + "venv", "__pycache__", "coverage", "packages", "tmp", "temp", "logs", +]); +// Subdirectorios candidatos, primero los más cercanos a la raíz (búsqueda en anchura). +// Acotado en profundidad y en número: esto corre en el arranque del instalador. +function candidateDirs(maxDepth = 3, maxDirs = 250) { + const salida = []; + let nivel = [CWD]; + for (let d = 0; d < maxDepth && salida.length < maxDirs; d++) { + const siguiente = []; + for (const dir of nivel) { + let entradas; + try { + entradas = fs.readdirSync(dir, { withFileTypes: true }); + } catch (_) { + continue; + } + for (const e of entradas) { + if (!e.isDirectory() || e.name.startsWith(".") || SKIP_SCAN.has(e.name)) continue; + const p = path.join(dir, e.name); + salida.push(p); + siguiente.push(p); + if (salida.length >= maxDirs) break; + } + } + nivel = siguiente; + } + return salida; +} // Spring se declara de muchas formas: starters, BOM, plugin de Gradle, imports del framework. // Antes solo se miraba /spring-boot/ en pom.xml y /spring/ en build.gradle, así que un Gradle // con Kotlin DSL o un proyecto Spring sin la palabra exacta se quedaba sin detectar. const SPRING_SIGNALS = /spring-boot|spring-framework|org\.springframework|io\.spring\.dependency-management/i; -const isSpringProject = () => javaBuildFiles().some((f) => fileHas(path.join(CWD, f), SPRING_SIGNALS)); +const isSpringProject = (dir = CWD) => javaBuildFiles(dir).some((f) => fileHas(path.join(dir, f), SPRING_SIGNALS)); -// Qué se vio en el directorio cuando la detección falla. Sin esto el usuario solo recibe -// "no se pudo detectar" y tiene que adivinar por qué. +// Qué se vio cuando la detección falla. Sin esto el usuario solo recibe "no se pudo detectar" +// y tiene que adivinar por qué. function detectionHint() { const java = javaBuildFiles(); if (java.length) { @@ -190,27 +225,29 @@ function detectionHint() { if (fs.existsSync(path.join(CWD, "go.mod"))) return "Vi go.mod: aún no hay overlay de Go en el kit."; if (fs.existsSync(path.join(CWD, "Cargo.toml"))) return "Vi Cargo.toml: aún no hay overlay de Rust en el kit."; if (fs.existsSync(path.join(CWD, "package.json"))) return "Vi package.json pero ningún framework reconocido en sus dependencias."; - return "No vi ningún archivo de proyecto reconocible en este directorio."; + return "No vi ningún archivo de proyecto reconocible aquí ni en los subdirectorios cercanos."; } -function detectStack() { - const pkg = readJSON(path.join(CWD, "package.json")) || {}; +// Reglas de detección aplicadas a UN directorio. Parametrizado a propósito: no todos los +// proyectos tienen su manifiesto en la raíz del repo (en .NET lo normal es Proyecto/Proyecto.csproj). +function detectIn(dir) { + const pkg = readJSON(path.join(dir, "package.json")) || {}; const deps = { ...pkg.dependencies, ...pkg.devDependencies }; - const req = path.join(CWD, "requirements.txt"); - const pyproj = path.join(CWD, "pyproject.toml"); + const req = path.join(dir, "requirements.txt"); + const pyproj = path.join(dir, "pyproject.toml"); // mobile - if (fs.existsSync(path.join(CWD, "pubspec.yaml"))) return "mobile/flutter"; + if (fs.existsSync(path.join(dir, "pubspec.yaml"))) return "mobile/flutter"; if (deps["react-native"] || deps["expo"]) return "mobile/react-native"; // frontend - if (fs.existsSync(path.join(CWD, "angular.json")) || deps["@angular/core"]) return "frontend/angular"; - if (hasFile("next.config.js", "next.config.mjs", "next.config.ts") || deps["next"]) return "frontend/nextjs"; // antes que react + if (fs.existsSync(path.join(dir, "angular.json")) || deps["@angular/core"]) return "frontend/angular"; + if (hasFileIn(dir, "next.config.js", "next.config.mjs", "next.config.ts") || deps["next"]) return "frontend/nextjs"; // antes que react // backend específicos - if (fs.existsSync(path.join(CWD, "nest-cli.json")) || deps["@nestjs/core"]) return "backend/nestjs"; - if (fs.existsSync(path.join(CWD, "foundry.toml"))) return "blockchain/solidity"; - if (dirHasExt(/\.(csproj|sln)$/)) return "backend/dotnet"; - if (isSpringProject()) return "backend/spring"; - if (fs.existsSync(path.join(CWD, "manage.py")) || fileHas(req, /(^|\n)\s*django\b/i) || fileHas(pyproj, /\bdjango\b/i)) return "backend/django"; - if (fs.existsSync(path.join(CWD, "composer.json"))) return "backend/php"; + if (fs.existsSync(path.join(dir, "nest-cli.json")) || deps["@nestjs/core"]) return "backend/nestjs"; + if (fs.existsSync(path.join(dir, "foundry.toml"))) return "blockchain/solidity"; + if (dirHasExt(/\.(csproj|sln)$/, dir)) return "backend/dotnet"; + if (isSpringProject(dir)) return "backend/spring"; + if (fs.existsSync(path.join(dir, "manage.py")) || fileHas(req, /(^|\n)\s*django\b/i) || fileHas(pyproj, /\bdjango\b/i)) return "backend/django"; + if (fs.existsSync(path.join(dir, "composer.json"))) return "backend/php"; if (fileHas(req, /fastapi/i) || fileHas(pyproj, /fastapi/i)) return "backend/fastapi"; // frontend genérico (después de next) if (deps["react"] && deps["react-dom"]) return "frontend/react"; @@ -219,6 +256,19 @@ function detectStack() { return null; } +// La raíz manda. Solo si ahí no hay nada se buscan subdirectorios, porque en .NET, Java y +// muchos repos el manifiesto vive un nivel más abajo (Proyecto/Proyecto.csproj, src/App/...). +// Devuelve dónde encontró la evidencia para que la decisión sea auditable, no un acto de fe. +function detectStack() { + const enRaiz = detectIn(CWD); + if (enRaiz) return { id: enRaiz, dir: null }; + for (const sub of candidateDirs()) { + const id = detectIn(sub); + if (id) return { id, dir: path.relative(CWD, sub) }; + } + return null; +} + const STACKS = { "backend/nestjs": 1, "backend/express": 1, "backend/fastapi": 1, "backend/django": 1, "backend/php": 1, "backend/spring": 1, "backend/dotnet": 1, @@ -761,13 +811,18 @@ async function main() { console.log(`║ Dev Starter Kit v${VERSION} — instalador `.slice(0, 45) + "║"); console.log("╚════════════════════════════════════════════╝"); - let stackId = FLAGS.stack || detectStack(); + const detectado = FLAGS.stack ? null : detectStack(); + let stackId = FLAGS.stack || (detectado && detectado.id); if (stackId && !STACKS[stackId]) { console.log(`⚠️ Stack "${stackId}" no reconocido.`); stackId = null; } if (stackId) { - console.log(`\n✅ Stack detectado: ${stackId}`); + const donde = detectado && detectado.dir ? ` (por ${detectado.dir}/, no por la raíz)` : ""; + console.log(`\n✅ Stack detectado: ${stackId}${donde}`); + if (detectado && detectado.dir) { + console.log(` El kit se instala aquí, en ${path.basename(CWD)}/, para que el CLAUDE.md cubra todo el repo.`); + } if (!(await confirm("¿Es correcto?", true))) stackId = null; } if (!stackId) { diff --git a/test/install-detect.test.cjs b/test/install-detect.test.cjs index 01f21bd..9ed6603 100644 --- a/test/install-detect.test.cjs +++ b/test/install-detect.test.cjs @@ -15,10 +15,13 @@ const path = require("node:path"); const INSTALL = path.join(__dirname, "..", "install.js"); // Crea un proyecto temporal con los archivos dados y corre el instalador en dry-run. +// Las claves pueden llevar subdirectorios ("PruebaConteo/PruebaConteo.csproj"). function detectar(archivos) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "kit-detect-")); for (const [nombre, contenido] of Object.entries(archivos)) { - fs.writeFileSync(path.join(dir, nombre), contenido); + const destino = path.join(dir, nombre); + fs.mkdirSync(path.dirname(destino), { recursive: true }); + fs.writeFileSync(destino, contenido); } const r = spawnSync("node", [INSTALL, "--yes", "--no-flow", "--no-external", "--dry-run"], { cwd: dir, @@ -73,6 +76,36 @@ test("detecta Angular por la dependencia @angular/core", () => { assert.equal(detectar({ "package.json": '{"dependencies":{"@angular/core":"^20.0.0"}}' }).stack, "frontend/angular"); }); +// El manifiesto no siempre está en la raíz del repo. En .NET es lo normal. +test("detecta C# con el .csproj en un subdirectorio (layout normal de .NET)", () => { + const r = detectar({ "PruebaConteo/PruebaConteo.csproj": '' }); + assert.equal(r.stack, "backend/dotnet"); + assert.match(r.salida, /PruebaConteo\//, "debe decir dónde encontró la evidencia"); +}); + +test("detecta C# en el layout src/App/App.csproj", () => { + assert.equal(detectar({ "src/App/App.csproj": '' }).stack, "backend/dotnet"); +}); + +test("detecta Spring y Angular anidados", () => { + assert.equal(detectar({ "backend/pom.xml": "spring-boot-starter" }).stack, "backend/spring"); + assert.equal(detectar({ "web/angular.json": "{}" }).stack, "frontend/angular"); +}); + +test("la raíz gana sobre cualquier subdirectorio", () => { + const r = detectar({ + "package.json": '{"dependencies":{"@nestjs/core":"^10"}}', + "sub/Legacy.csproj": '', + }); + assert.equal(r.stack, "backend/nestjs", "el proyecto de la raíz manda"); + assert.doesNotMatch(r.salida, /no por la raíz/, "no debe anunciar evidencia anidada"); +}); + +test("no confunde artefactos de build con el proyecto", () => { + assert.equal(detectar({ "obj/Debug/generado.csproj": "" }).stack, null, "obj/ debe ignorarse"); + assert.equal(detectar({ "node_modules/x/angular.json": "{}" }).stack, null, "node_modules/ debe ignorarse"); +}); + test("la pista distingue otros ecosistemas sin overlay", () => { assert.match(detectar({ "go.mod": "module x" }).salida, /Go/); assert.match(detectar({ "Cargo.toml": "[package]" }).salida, /Rust/);