pull-request-split-advisor 3.2.3 → 3.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -27,11 +27,17 @@ La capa de IA ofrece tres puntos de enriquecimiento: **mejora de mensajes de com
27
27
 
28
28
  La v3.0.0 también mejora el **apply**: rama de respaldo con snapshot completo del working tree, re-prompt en nombres duplicados en lugar de rollback, y nomenclatura de ramas con mayúsculas en la descripción (p. ej. `feature/TEAM-123-PRUEBA-IA`). Si la IA no está configurada o falla en cualquier punto, la herramienta continúa en modo heurístico sin interrupciones.
29
29
 
30
+ A partir de la **v3.1.0**, el proyecto incluye una **suite completa de tests** (Vitest, 199 tests, 12 suites) que cubre todos los módulos core, AI, git y configuración con mocks de dependencias externas (git, fs). A partir de la **v3.1.1**, el proveedor `copilot` solo puede activarse dentro de **Visual Studio Code** — fuera del IDE se emite un aviso y se desactiva automáticamente sin interrumpir el análisis.
31
+
30
32
  A partir de la **v3.2.0**, se añade el subcomando **`score`** que calcula y muestra el score del estado actual de la rama sin dividir en PRs, exportando también un reporte HTML independiente (`pr-split-score.html`) con desglose de métricas, valores brutos y detalle de archivos.
31
33
 
32
- A partir de la **v3.2.1**, cada archivo analizado recibe una **clasificación de origen** basada en su posición en el árbol git: `REMOTO` (cambios ya publicados en el remoto), `LOCAL` (commiteado solo en local), `WIP` (modificación sin commitear aún) o `NUEVO` (archivo sin rastrear). Esta información aparece como badge de color en la salida terminal y en la columna *Origen* de las tablas de archivos en ambos reportes HTML. El subcomando `score` utiliza exclusivamente los archivos en estado `WIP` o `NUEVO` para las métricas; si el working tree está limpio (todos los cambios ya están en commits), el comando informa y sale sin calcular un score basado en datos erróneos.
34
+ A partir de la **v3.2.1**, cada archivo analizado recibe una **clasificación de origen** basada en su posición en el árbol git: `REMOTO` (cambios ya publicados en el remoto), `LOCAL` (commiteado solo en local), `WIP` (modificación sin commitear aún) o `NUEVO` (archivo sin rastrear). Esta información aparece como badge de color en la salida terminal y en la columna *Origen* de las tablas de archivos en ambos reportes HTML.
33
35
 
34
- A partir de la **v3.1.0**, el proyecto incluye una **suite completa de tests** (Vitest, 199 tests, 12 suites) que cubre todos los módulos core, AI, git y configuración con mocks de dependencias externas (git, fs). A partir de la **v3.1.1**, el proveedor `copilot` solo puede activarse dentro de **Visual Studio Code** — fuera del IDE se emite un aviso y se desactiva automáticamente sin interrumpir el análisis.
36
+ A partir de la **v3.2.2**, el subcomando `score` evalúa **exclusivamente archivos WIP y sin rastrear**. Los archivos ya commiteados (LOCAL o REMOTO) se muestran en el reporte con su badge pero no influyen en las métricas. Si el working tree está limpio, el comando emite una advertencia y sale sin calcular un score incorrecto.
37
+
38
+ A partir de la **v3.2.3**, el reporte HTML principal (`pr-split-report.html`) y el resumen en terminal calculan todos sus agregados (hero, tarjetas de resumen, footer) **solo sobre archivos WIP**. Los archivos LOCAL y REMOTO siguen apareciendo en la tabla de detalle con sus badges, acompañados de un banner informativo que los identifica como referencias no incluidas en las métricas.
39
+
40
+ A partir de la **v3.2.4**, la herramienta detecta si la rama actual ya tiene commits adelantados respecto a la base antes de iniciar el análisis. En ese caso emite una **advertencia de integridad de cascada** con el comando exacto para crear la nueva rama correctamente: si existen ramas hermanas de la misma historia con commits adelantados, sugiere crear la siguiente rama desde la punta de la rama hermana más reciente (para continuar la cascada); si no, sugiere partir desde la base. El apply queda deshabilitado en esa ejecución para no romper el plan apilado. Esta versión también incluye tres correcciones internas: eliminación del doble sufijo `-wip` al sugerir nombres de rama, remplazo de `Math.max(...spread)` por `reduce` en la generación de reportes HTML (evita `RangeError` en repositorios con muchos archivos) y limpieza de un punto y coma duplicado en el cálculo de cobertura de tests.
35
41
 
36
42
  ---
37
43
 
package/dist/cli.js CHANGED
@@ -28,7 +28,7 @@ import { parseAndValidateWorkingBranch } from "./git/branch-naming.js";
28
28
  import { loadConfig } from "./config/config.js";
29
29
  import { APP_BIN, APP_NAME, APP_VERSION } from "./shared/constants.js";
30
30
  import { applyPlan } from "./git/executor.js";
31
- import { detectRemote, fetchQuiet, getCurrentBranch, localAheadCount, localBehindCount, remoteTrackingExists, requireCleanIndex, requireGitRepo } from "./git/git.js";
31
+ import { detectRemote, fetchQuiet, findCascadeParent, getCurrentBranch, localAheadCount, localBehindCount, remoteTrackingExists, requireCleanIndex, requireGitRepo } from "./git/git.js";
32
32
  import { buildBlocks, buildDependencyEdges, computeFileOrigins, findBestPlan, gatherChangedFiles, getFileStats } from "./core/planner.js";
33
33
  import { resetTrackedFilesCache } from "./shared/utils.js";
34
34
  import { exportJson, writeHtmlReport, writeScoreHtmlReport } from "./output/report.js";
@@ -108,7 +108,6 @@ function printSummary(fileStats, config, currentBranch, baseBranch) {
108
108
  const renamedFiles = statsBase.filter((f) => f.changeType === "R").length;
109
109
  const largeFiles = statsBase.filter((f) => f.lines > config.largeFileThreshold).length;
110
110
  const testCoveragePercent = config.testCoveragePercent;
111
- ;
112
111
  ui.header(APP_NAME.toUpperCase(), "Asesor de División de PRs", [
113
112
  ["Rama actual", currentBranch],
114
113
  ["Rama base del PR", baseBranch],
@@ -355,9 +354,53 @@ async function main() {
355
354
  }
356
355
  ui.ok(`Rama base '${baseBranch}' verificada en '${remote}' (última versión).`);
357
356
  const aheadCount = localAheadCount(baseBranch);
357
+ let branchHasCommits = false;
358
+ // cascadeWarning se construye aquí (scope del action) para poder pasarlo
359
+ // al reporte HTML aunque el usuario haya elegido continuar el análisis.
360
+ let cascadeWarning;
358
361
  if (aheadCount > 0) {
359
- ui.warn(`La rama '${currentBranch}' ya tiene ${aheadCount} commit(s) adelantados respecto a '${baseBranch}'.\n` +
360
- `Los archivos ya commiteados aparecerán en el análisis pero no podrán re-commitearse al aplicar el plan.`);
362
+ branchHasCommits = true;
363
+ // Buscar la última rama hermana de la cascada (mismo tipo/equipo-número)
364
+ // que tenga commits adelantados. Si existe, el usuario debe continuar
365
+ // la cascada desde esa rama; si no, debe partir desde la base.
366
+ const cascadeParent = findCascadeParent(currentBranch, baseBranch);
367
+ const branchFrom = cascadeParent ?? baseBranch;
368
+ const suggestedBranch = currentBranch.replace(/^(feature|fix|bugfix|hotfix|refactor|chore|docs|test|perf|ci)\/(.+?)(-wip|-backup-.+)?$/, (_, type, rest) => `${type}/${rest}-wip`);
369
+ cascadeWarning = { aheadCount, cascadeParent, branchFrom, suggestedBranch };
370
+ const cascadeLines = cascadeParent
371
+ ? [
372
+ `Se detectó la rama '${cascadeParent}' como parte de la misma`,
373
+ `cascada (mismo tipo/equipo/historia). Para mantener el orden`,
374
+ `correcto de PRs apilados, la nueva rama debe crearse desde`,
375
+ `esa rama y no desde la base:`,
376
+ ]
377
+ : [
378
+ `No se encontraron otras ramas de la misma cascada.`,
379
+ `Crea la nueva rama directamente desde la base:`,
380
+ ];
381
+ ui.card("⚠ INTEGRIDAD DEL PLAN EN CASCADA COMPROMETIDA", [
382
+ `La rama '${currentBranch}' ya tiene ${aheadCount} commit${aheadCount !== 1 ? "s" : ""}`,
383
+ `adelantados respecto a '${baseBranch}'.`,
384
+ "",
385
+ "Para que el plan de PRs en cascada funcione correctamente, cada",
386
+ "rama del plan debe partir desde un estado limpio relativo a la",
387
+ "rama base. Si esta rama ya tiene commits previos, las ramas",
388
+ "derivadas los heredarán todos, rompiendo la separación de cambios",
389
+ "y haciendo los PRs imposibles de revisar de forma aislada.",
390
+ "",
391
+ ui.badge("RECOMENDACIÓN", "yellow") + " " + cascadeLines[0],
392
+ ...cascadeLines.slice(1).map((l) => " " + l),
393
+ ` git checkout ${branchFrom}`,
394
+ ` git checkout -b ${suggestedBranch}`,
395
+ "",
396
+ "Puedes continuar para ver el análisis (sin aplicar el plan).",
397
+ "La opción --apply quedará deshabilitada en esta ejecución."
398
+ ], "red");
399
+ const continueAnyway = await ui.confirm("¿Deseas continuar de todas formas (solo análisis, sin aplicar)?");
400
+ if (!continueAnyway) {
401
+ closeReadlineInterface();
402
+ return;
403
+ }
361
404
  }
362
405
  // ─── Pipeline de análisis ────────────────────────────────────────────────
363
406
  ui.spinner.start("Analizando cambios del working tree...");
@@ -405,7 +448,7 @@ async function main() {
405
448
  printPlans(plans, baseBranch, currentBranch, config);
406
449
  // ─── Exportar reporte HTML ───────────────────────────────────────────────
407
450
  const wipFileStats = fileStats.filter((f) => !f.origin || f.origin === "working-tree" || f.origin === "untracked");
408
- const reportInput = { currentBranch, baseBranch, config, fileStats, wipFileStats, deps, blocks, plans };
451
+ const reportInput = { currentBranch, baseBranch, config, fileStats, wipFileStats, deps, blocks, plans, cascadeWarning };
409
452
  const htmlFile = "pr-split-report.html";
410
453
  writeHtmlReport(htmlFile, reportInput);
411
454
  ui.ok(`Reporte HTML generado: ${htmlFile}`);
@@ -427,7 +470,9 @@ async function main() {
427
470
  // ─── Decidir si aplicar el plan ───────────────────────────────────────────
428
471
  // --apply omite la pregunta y ejecuta directamente (modo no interactivo/CI).
429
472
  // Sin el flag, siempre se pregunta al usuario tras ver el reporte y el HTML.
430
- const shouldApply = !!options.apply || await ui.confirm("¿Deseas aplicar el plan ahora?");
473
+ // Si la rama ya tenía commits adelantados, el apply quedó deshabilitado
474
+ // para preservar la integridad del plan en cascada.
475
+ const shouldApply = !branchHasCommits && (!!options.apply || await ui.confirm("¿Deseas aplicar el plan ahora?"));
431
476
  if (shouldApply) {
432
477
  await enrichCommitTickets(plans, config);
433
478
  try {
@@ -439,6 +484,11 @@ async function main() {
439
484
  process.exit(1);
440
485
  }
441
486
  }
487
+ else if (branchHasCommits) {
488
+ ui.warn("Apply deshabilitado: la rama ya tenía commits adelantados respecto a la base.\n" +
489
+ "Crea una nueva rama desde la base para poder aplicar el plan sin romper\n" +
490
+ "la integridad de los PRs en cascada.");
491
+ }
442
492
  else {
443
493
  ui.info("Sin cambios en git.");
444
494
  }
package/dist/git/git.js CHANGED
@@ -226,6 +226,51 @@ export function localAheadCount(baseBranch) {
226
226
  const count = shSafe(`git rev-list ${q(baseBranch)}..HEAD --count`);
227
227
  return parseInt(count, 10) || 0;
228
228
  }
229
+ /**
230
+ * Busca la última rama local de la misma "familia" (mismo prefijo tipo/equipo-número)
231
+ * que tiene commits adelantados respecto a `baseBranch`, excluyendo `currentBranch`.
232
+ *
233
+ * Útil para sugerir al usuario que cree la siguiente rama de la cascada desde
234
+ * la punta de la rama hermana más reciente en lugar de desde la rama base.
235
+ *
236
+ * @param currentBranch - Rama actual (se excluye de la búsqueda).
237
+ * @param baseBranch - Rama base del análisis.
238
+ * @returns Nombre de la rama hermana más reciente con commits adelantados, o `null`.
239
+ */
240
+ export function findCascadeParent(currentBranch, baseBranch) {
241
+ // Extraer el prefijo familia: tipo/EQUIPO-NUMERO
242
+ const familyMatch = currentBranch.match(/^(feature|fix|bugfix|hotfix|refactor|chore|docs|test|perf|ci)\/([A-Z]+-\d+)/i);
243
+ if (!familyMatch)
244
+ return null;
245
+ const familyPrefix = `${familyMatch[1]}/${familyMatch[2].toUpperCase()}`;
246
+ // Listar todas las ramas locales
247
+ const raw = shSafe("git branch --format=%(refname:short)");
248
+ if (!raw)
249
+ return null;
250
+ const candidates = raw
251
+ .split("\n")
252
+ .map((b) => b.trim())
253
+ .filter((b) => b &&
254
+ b !== currentBranch &&
255
+ b.toUpperCase().startsWith(familyPrefix.toUpperCase()));
256
+ if (candidates.length === 0)
257
+ return null;
258
+ // De los candidatos, quedarnos con los que tengan commits adelantados
259
+ // respecto a la base y ordenarlos por committerdate descendente
260
+ const withAhead = candidates.filter((b) => {
261
+ const count = shSafe(`git rev-list ${q(baseBranch)}..${q(b)} --count`);
262
+ return (parseInt(count, 10) || 0) > 0;
263
+ });
264
+ if (withAhead.length === 0)
265
+ return null;
266
+ // El más reciente es el que tiene el commit más nuevo
267
+ const sorted = withAhead.sort((a, b) => {
268
+ const dateA = shSafe(`git log -1 --format=%ct ${q(a)}`);
269
+ const dateB = shSafe(`git log -1 --format=%ct ${q(b)}`);
270
+ return (parseInt(dateB, 10) || 0) - (parseInt(dateA, 10) || 0);
271
+ });
272
+ return sorted[0] ?? null;
273
+ }
229
274
  /**
230
275
  * Hace push de una rama local al remoto indicado.
231
276
  *
@@ -436,7 +436,7 @@ export function renderHtmlReport(input) {
436
436
  const optimalPlans = plans.filter((p) => p.score >= config.targetScore).length;
437
437
  const acceptablePlans = plans.filter((p) => p.score >= warnThreshold && p.score < config.targetScore).length;
438
438
  const riskPlans = plans.filter((p) => p.score < warnThreshold).length;
439
- const maxLinesForBar = fileStats.length > 0 ? Math.max(...fileStats.map((f) => f.lines)) : 1;
439
+ const maxLinesForBar = fileStats.length > 0 ? fileStats.reduce((max, f) => f.lines > max ? f.lines : max, 0) : 1;
440
440
  const timestamp = new Date().toLocaleString("es-PE", {
441
441
  year: "numeric", month: "long", day: "numeric",
442
442
  hour: "2-digit", minute: "2-digit"
@@ -498,6 +498,32 @@ export function renderHtmlReport(input) {
498
498
  </div>
499
499
  </div>
500
500
  </header>
501
+ <!-- ══ AVISO DE INTEGRIDAD DE CASCADA (condicional) ════════════ -->
502
+ ${input.cascadeWarning ? (() => {
503
+ const cw = input.cascadeWarning;
504
+ const parentNote = cw.cascadeParent
505
+ ? `Se detectó la rama <code>${esc(cw.cascadeParent)}</code> como parte de la misma cascada.
506
+ Para mantener el orden correcto de PRs apilados, la nueva rama debe crearse desde esa rama.`
507
+ : `No se encontraron otras ramas de la misma cascada. Crea la nueva rama directamente desde la base.`;
508
+ return `<section class="disclaimer-banner" style="background:rgba(239,68,68,.12);border-color:#f87171" role="alert" aria-label="Aviso de integridad de cascada">
509
+ <div class="disclaimer-icon">⚠️</div>
510
+ <div class="disclaimer-body">
511
+ <strong>INTEGRIDAD DEL PLAN EN CASCADA COMPROMETIDA</strong>
512
+ <p>
513
+ La rama <strong>${esc(currentBranch)}</strong> ya tiene
514
+ <strong>${cw.aheadCount} commit${cw.aheadCount !== 1 ? "s" : ""}</strong>
515
+ adelantados respecto a <strong>${esc(baseBranch)}</strong>.
516
+ Las ramas derivadas heredarán esos commits, rompiendo la separación de cambios
517
+ y haciendo los PRs imposibles de revisar de forma aislada.
518
+ </p>
519
+ <p>${parentNote}</p>
520
+ <p><strong>Comandos recomendados:</strong></p>
521
+ <pre style="background:rgba(0,0,0,.25);padding:10px 14px;border-radius:6px;font-size:.85em;margin:8px 0 0"># Crear la siguiente rama de la cascada\ngit checkout ${esc(cw.branchFrom)}\ngit checkout -b ${esc(cw.suggestedBranch)}</pre>
522
+ <p class="disclaimer-footer" style="margin-top:8px">El apply estuvo deshabilitado en esta ejecución para preservar la integridad del plan.</p>
523
+ </div>
524
+ </section>`;
525
+ })() : ""}
526
+
501
527
  <!-- ══ AVISO DE RESPONSABILIDAD ═══════════════════════════════ -->
502
528
  <section class="disclaimer-banner" role="alert" aria-label="Aviso de responsabilidad">
503
529
  <div class="disclaimer-icon">⚠️</div>
@@ -748,7 +774,7 @@ export function renderScoreHtmlReport(input) {
748
774
  const totalLines = fileStats.reduce((sum, f) => sum + f.lines, 0);
749
775
  const totalAdditions = fileStats.reduce((sum, f) => sum + f.additions, 0);
750
776
  const totalDeletions = fileStats.reduce((sum, f) => sum + f.deletions, 0);
751
- const maxLinesForBar = totalFiles > 0 ? Math.max(...fileStats.map((f) => f.lines)) : 1;
777
+ const maxLinesForBar = totalFiles > 0 ? fileStats.reduce((max, f) => f.lines > max ? f.lines : max, 0) : 1;
752
778
  const label = scoreLabel(result.complexity, config.targetScore);
753
779
  const warnThreshold = config.targetScore > 4 ? 4 : Math.max(0, config.targetScore - 1);
754
780
  const scoreClass = result.complexity >= config.targetScore ? "green" : result.complexity >= warnThreshold ? "yellow" : "red";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pull-request-split-advisor",
3
- "version": "3.2.3",
3
+ "version": "3.2.4",
4
4
  "description": "CLI that analyses your Git working tree and suggests how to split changes into smaller, reviewable pull requests and commits.",
5
5
  "keywords": [
6
6
  "git",