Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
103 changes: 79 additions & 24 deletions install.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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";
Expand All @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
35 changes: 34 additions & 1 deletion test/install-detect.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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": '<Project Sdk="Microsoft.NET.Sdk" />' });
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": '<Project Sdk="Microsoft.NET.Sdk" />' }).stack, "backend/dotnet");
});

test("detecta Spring y Angular anidados", () => {
assert.equal(detectar({ "backend/pom.xml": "<project>spring-boot-starter</project>" }).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": '<Project Sdk="Microsoft.NET.Sdk" />',
});
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": "<Project />" }).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/);
Expand Down
Loading