pull-request-split-advisor 3.1.2 → 3.2.1

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,6 +27,8 @@ 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.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
+
30
32
  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
33
 
32
34
  ---
@@ -38,6 +40,7 @@ A partir de la **v3.1.0**, el proyecto incluye una **suite completa de tests** (
38
40
  - Cálculo de score de calidad por rama (0–5) con factores ponderados
39
41
  - Generación de nombres de rama y mensajes de commit convencionales
40
42
  - Reporte HTML interactivo exportado automáticamente (8 secciones: hero ejecutivo, resumen de cambios, TOC con barras de score, detalle de archivos, dependencias, bloques, plan de commits con línea de tiempo y comandos git listos para ejecutar) — ver [formato completo](docs/FEATURES.md#reporte-html-interactivo)
43
+ - **`score`**: subcomando (v3.2.0+) que calcula el score del estado actual sin dividir y genera `pr-split-score.html` con desglose completo de métricas
41
44
  - Exportación del plan en formato JSON para integraciones externas
42
45
  - Historial de scores con seguimiento entre análisis
43
46
  - Validaciones de nomenclatura de ramas y estado del working tree
@@ -74,6 +77,10 @@ pr-split-advisor --apply
74
77
  # Especificar rama base de forma explícita
75
78
  pr-split-advisor --base develop
76
79
 
80
+ # Ver el score actual de la rama sin dividir en PRs
81
+ pr-split-advisor score
82
+ pr-split-advisor score --base develop
83
+
77
84
  # Consultar historial de análisis anteriores
78
85
  pr-split-advisor --stats
79
86
 
@@ -60,7 +60,7 @@ async function testApiKeyConnection(provider, model, apiKey, apiKeyEnvVar) {
60
60
  model,
61
61
  apiKey,
62
62
  apiKeyEnvVar,
63
- features: { commitMessages: false, branchDescriptions: false },
63
+ features: { commitMessages: false, branchDescriptions: false, planRebalance: false },
64
64
  timeoutMs: 10000,
65
65
  maxTokens: 16,
66
66
  },
@@ -227,7 +227,7 @@ export async function runConfigWizard() {
227
227
  * Valida la key contra el proveedor antes de guardar. Si falla, aborta con error.
228
228
  *
229
229
  * @param apiKey - La API key del proveedor (literal, no nombre de env var).
230
- * @param provider - ID del proveedor: "groq" | "github" | "copilot". Predeterminado: "groq".
230
+ * @param provider - ID del proveedor: "groq" | "copilot". Predeterminado: "groq".
231
231
  */
232
232
  export async function runConfigWithKey(apiKey, provider = "groq") {
233
233
  const configPath = resolve(process.cwd(), CONFIG_FILE);
package/dist/cli.js CHANGED
@@ -30,9 +30,10 @@ import { APP_BIN, APP_NAME, APP_VERSION } from "./shared/constants.js";
30
30
  import { applyPlan } from "./git/executor.js";
31
31
  import { detectRemote, fetchQuiet, getCurrentBranch, localAheadCount, localBehindCount, remoteTrackingExists, requireCleanIndex, requireGitRepo } from "./git/git.js";
32
32
  import { buildBlocks, buildDependencyEdges, findBestPlan, gatherChangedFiles, getFileStats } from "./core/planner.js";
33
- import { exportJson, writeHtmlReport } from "./output/report.js";
33
+ import { exportJson, writeHtmlReport, writeScoreHtmlReport } from "./output/report.js";
34
34
  import { appendHistory, readHistory } from "./core/history.js";
35
35
  import { buildSuggestedMessage } from "./core/commit-planner.js";
36
+ import { scorePullRequest } from "./core/scoring.js";
36
37
  import { ui, closeReadlineInterface } from "./output/ui.js";
37
38
  import { changeTypeLabel } from "./shared/utils.js";
38
39
  import { aiEnrichPlans } from "./ai/enricher.js";
@@ -279,6 +280,7 @@ async function main() {
279
280
  config.jsonOutputFile,
280
281
  "pr-split-advisor.config.json",
281
282
  "pr-split-report.html",
283
+ "pr-split-score.html",
282
284
  config.historyFile
283
285
  ].filter(Boolean);
284
286
  if (config.verbose) {
@@ -417,6 +419,85 @@ async function main() {
417
419
  }
418
420
  closeReadlineInterface();
419
421
  });
422
+ // ─── Subcomando: score ───────────────────────────────────────────────────
423
+ program
424
+ .command("score")
425
+ .description("Muestra el score del estado actual de la rama sin dividir")
426
+ .option("-b, --base <branch>", "rama base para calcular los cambios")
427
+ .action(async (opts) => {
428
+ const config = loadConfig();
429
+ try {
430
+ requireGitRepo();
431
+ }
432
+ catch (error) {
433
+ ui.error(error.message);
434
+ closeReadlineInterface();
435
+ process.exit(1);
436
+ }
437
+ const baseBranch = opts.base ?? config.baseBranch ?? "main";
438
+ const currentBranch = getCurrentBranch();
439
+ config.runtimeExcludedFiles = [
440
+ config.jsonOutputFile,
441
+ "pr-split-advisor.config.json",
442
+ "pr-split-report.html",
443
+ "pr-split-score.html",
444
+ config.historyFile
445
+ ].filter(Boolean);
446
+ ui.spinner.start("Calculando score del estado actual...");
447
+ const changedFiles = gatherChangedFiles(config, baseBranch);
448
+ if (!changedFiles.length) {
449
+ ui.spinner.stop();
450
+ ui.warn("No hay cambios respecto a la rama base.");
451
+ closeReadlineInterface();
452
+ return;
453
+ }
454
+ const fileStats = getFileStats(changedFiles, baseBranch);
455
+ ui.spinner.stop("Calculo completado.");
456
+ const totalFiles = fileStats.length;
457
+ const totalLines = fileStats.reduce((sum, f) => sum + f.lines, 0);
458
+ const aheadCommits = localAheadCount(baseBranch);
459
+ const commitCount = Math.max(aheadCommits, 1);
460
+ const filesPerCommit = Number((totalFiles / commitCount).toFixed(2));
461
+ const avgLinesPerCommit = Math.round(totalLines / commitCount);
462
+ const result = scorePullRequest({ commitCount, filesPerCommit, avgLinesPerCommit, totalLinesChanged: totalLines }, config);
463
+ const warnThreshold = config.targetScore > 4 ? 4 : Math.max(0, config.targetScore - 1);
464
+ const scoreColor = ui.scoreColor(result.complexity, config.targetScore);
465
+ const statusBadge = result.complexity >= config.targetScore
466
+ ? ui.badge("ÓPTIMO", "green")
467
+ : result.complexity >= warnThreshold
468
+ ? ui.badge("ACEPTABLE", "yellow")
469
+ : ui.badge("RIESGO", "red");
470
+ const scoreHtmlFile = "pr-split-score.html";
471
+ writeScoreHtmlReport(scoreHtmlFile, {
472
+ currentBranch,
473
+ baseBranch,
474
+ config,
475
+ fileStats,
476
+ commitCount,
477
+ filesPerCommit,
478
+ avgLinesPerCommit,
479
+ result
480
+ });
481
+ ui.ok(`Reporte HTML generado: ${scoreHtmlFile}`);
482
+ ui.card(`Score actual de la rama: ${currentBranch}`, [
483
+ `${statusBadge}`,
484
+ "",
485
+ `Rama base: ${baseBranch}`,
486
+ `Archivos cambiados: ${totalFiles}`,
487
+ `Líneas totales: ${totalLines}`,
488
+ `Commits adelantados: ${commitCount}`,
489
+ `Archivos por commit: ${filesPerCommit}`,
490
+ `Líneas por commit (avg):${avgLinesPerCommit}`,
491
+ "",
492
+ `Score: ${ui.score(result.complexity, config.targetScore)}`,
493
+ `Score objetivo: ${config.targetScore}`,
494
+ "",
495
+ ...Object.values(result.metrics).map((m) => ` ${m.label}: valor=${m.rawValue} pts=${m.points} pond=${m.weightedScore}`),
496
+ "",
497
+ `Recomendación: ${result.recommendation}`
498
+ ], scoreColor);
499
+ closeReadlineInterface();
500
+ });
420
501
  // ─── Subcomando: config ──────────────────────────────────────────────────
421
502
  program
422
503
  .command("config")
@@ -304,7 +304,7 @@ export function loadConfig(configPath = "pr-split-advisor.config.json") {
304
304
  }
305
305
  if (isObject(ai["features"])) {
306
306
  const f = ai["features"];
307
- for (const flag of ["commitMessages", "branchDescriptions"]) {
307
+ for (const flag of ["commitMessages", "branchDescriptions", "planRebalance"]) {
308
308
  if (flag in f && typeof f[flag] !== "boolean") {
309
309
  throw new Error(`Configuración inválida: "ai.features.${flag}" debe ser true o false (recibido: ${JSON.stringify(f[flag])}).`);
310
310
  }
@@ -188,9 +188,8 @@ export const defaultConfig = {
188
188
  //
189
189
  // Configurar vía `pr-split-advisor config` o editando directamente el JSON.
190
190
  //
191
- // Opciones de `provider` soportadas: "groq" | "github" | "copilot"
191
+ // Opciones de `provider` soportadas: "groq" | "copilot"
192
192
  // groq → requiere GROQ_API_KEY (console.groq.com, free tier)
193
- // github → requiere GITHUB_TOKEN (github.com/marketplace/models, free)
194
193
  // copilot → sin token — usa automáticamente credenciales de gh CLI
195
194
  //
196
195
  // API key: preferir `apiKeyEnvVar` (ej: GROQ_API_KEY) antes que literal `apiKey`.
@@ -706,6 +706,141 @@ function handleCopy(btn) {
706
706
  export function writeHtmlReport(filePath, input) {
707
707
  writeFileSync(filePath, renderHtmlReport(input), "utf8");
708
708
  }
709
+ /**
710
+ * Genera el reporte HTML del subcomando `score` como string.
711
+ * Muestra el score actual de la rama sin dividir, con desglose de métricas y detalle de archivos.
712
+ */
713
+ export function renderScoreHtmlReport(input) {
714
+ const { currentBranch, baseBranch, config, fileStats, commitCount, filesPerCommit, avgLinesPerCommit, result } = input;
715
+ const totalFiles = fileStats.length;
716
+ const totalLines = fileStats.reduce((sum, f) => sum + f.lines, 0);
717
+ const totalAdditions = fileStats.reduce((sum, f) => sum + f.additions, 0);
718
+ const totalDeletions = fileStats.reduce((sum, f) => sum + f.deletions, 0);
719
+ const maxLinesForBar = totalFiles > 0 ? Math.max(...fileStats.map((f) => f.lines)) : 1;
720
+ const label = scoreLabel(result.complexity, config.targetScore);
721
+ const warnThreshold = config.targetScore > 4 ? 4 : Math.max(0, config.targetScore - 1);
722
+ const scoreClass = result.complexity >= config.targetScore ? "green" : result.complexity >= warnThreshold ? "yellow" : "red";
723
+ const timestamp = new Date().toLocaleString("es-PE", {
724
+ year: "numeric", month: "long", day: "numeric",
725
+ hour: "2-digit", minute: "2-digit"
726
+ });
727
+ const metricRows = Object.values(result.metrics).map((m) => {
728
+ const ptsPct = Math.round((m.points / 5) * 100);
729
+ const barColor = m.points >= 4 ? "#16a34a" : m.points >= 3 ? "#d97706" : "#dc2626";
730
+ const ptsClass = m.points >= 4 ? "pts-good" : m.points >= 3 ? "pts-warn" : "pts-bad";
731
+ return `<tr>
732
+ <td><code class="metric-code">${esc(m.code)}</code></td>
733
+ <td class="metric-label">${esc(m.label)}</td>
734
+ <td class="metric-value">${m.rawValue}</td>
735
+ <td>
736
+ <div class="pts-bar-wrap"><div class="pts-bar" style="width:${ptsPct}%;background:${barColor}"></div></div>
737
+ <span class="${ptsClass}">${m.points} / 5</span>
738
+ </td>
739
+ <td class="metric-weight">${(m.weight * 100).toFixed(0)}%</td>
740
+ <td class="metric-contrib">${m.weightedScore.toFixed(3)}</td>
741
+ </tr>`;
742
+ }).join("");
743
+ return `<!doctype html>
744
+ <html lang="es">
745
+ <head>
746
+ <meta charset="utf-8" />
747
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
748
+ <title>Score actual – ${esc(currentBranch)}</title>
749
+ <style>${buildReportStyles()}</style>
750
+ </head>
751
+ <body>
752
+ <div class="container">
753
+
754
+ <header class="hero">
755
+ <div class="hero-top">
756
+ <div class="hero-title-area">
757
+ <h1>📊 Score actual de la rama</h1>
758
+ <p>Análisis del estado presente sin dividir en múltiples PRs.</p>
759
+ </div>
760
+ <div class="hero-timestamp">📅 ${timestamp}</div>
761
+ </div>
762
+ <div class="hero-grid">
763
+ <div class="hero-item"><div class="label">Rama analizada</div><div class="value">⎇ ${esc(currentBranch)}</div></div>
764
+ <div class="hero-item"><div class="label">Rama base</div><div class="value">⎇ ${esc(baseBranch)}</div></div>
765
+ <div class="hero-item"><div class="label">Score actual</div><div class="value">${result.complexity.toFixed(2)}</div></div>
766
+ <div class="hero-item"><div class="label">Score objetivo</div><div class="value">${config.targetScore} / 5.00</div></div>
767
+ <div class="hero-item"><div class="label">Archivos</div><div class="value">${totalFiles}</div></div>
768
+ <div class="hero-item"><div class="label">Líneas totales</div><div class="value">${totalLines}</div></div>
769
+ <div class="hero-item"><div class="label">Commits adelantados</div><div class="value">${commitCount}</div></div>
770
+ <div class="hero-item"><div class="label">Estado</div><div class="value score-label-${scoreClass}">${esc(label)}</div></div>
771
+ </div>
772
+ </header>
773
+
774
+ <section class="section">
775
+ <h2 class="section-heading">🎯 Score</h2>
776
+ <div class="card" style="display:flex;align-items:center;gap:32px;padding:24px">
777
+ ${buildScoreGaugeSvg(result.complexity, config.targetScore)}
778
+ <div>
779
+ <div style="font-size:2rem;font-weight:800;color:${result.complexity >= config.targetScore ? "#16a34a" : result.complexity >= warnThreshold ? "#d97706" : "#dc2626"}">${result.complexity.toFixed(2)} / 5.00</div>
780
+ <div class="badge ${scoreClass}" style="margin-top:8px">${esc(label)}</div>
781
+ <p style="margin-top:12px;color:#64748b">${esc(result.recommendation)}</p>
782
+ </div>
783
+ </div>
784
+ </section>
785
+
786
+ <section class="section">
787
+ <h2 class="section-heading">📊 Desglose de métricas</h2>
788
+ <table class="metrics-table">
789
+ <thead>
790
+ <tr><th>Cód.</th><th>Métrica</th><th>Valor medido</th><th>Puntos</th><th>Peso</th><th>Aporte</th></tr>
791
+ </thead>
792
+ <tbody>
793
+ ${metricRows}
794
+ <tr class="metrics-total-row">
795
+ <td colspan="5" style="text-align:right;font-weight:700;">Score total</td>
796
+ <td class="metric-total-value">${result.complexity.toFixed(3)}</td>
797
+ </tr>
798
+ </tbody>
799
+ </table>
800
+ </section>
801
+
802
+ <section class="section">
803
+ <h2 class="section-heading">📈 Valores brutos usados</h2>
804
+ <div class="summary-grid">
805
+ <div class="stat-card blue"><div class="label">Commits</div><div class="value">${commitCount}</div></div>
806
+ <div class="stat-card blue"><div class="label">Archivos / commit</div><div class="value">${filesPerCommit}</div></div>
807
+ <div class="stat-card blue"><div class="label">Líneas / commit (avg)</div><div class="value">${avgLinesPerCommit}</div></div>
808
+ <div class="stat-card blue"><div class="label">Total de líneas</div><div class="value">${totalLines}</div></div>
809
+ <div class="stat-card green"><div class="label">Líneas agregadas</div><div class="value">+${totalAdditions}</div></div>
810
+ <div class="stat-card red"><div class="label">Líneas eliminadas</div><div class="value">-${totalDeletions}</div></div>
811
+ </div>
812
+ </section>
813
+
814
+ <section class="section">
815
+ <h2 class="section-heading">📄 Detalle de archivos</h2>
816
+ <table>
817
+ <thead>
818
+ <tr><th>Tipo</th><th>Archivo</th><th>Líneas</th><th>Cambios (+/-)</th><th>Prioridad</th></tr>
819
+ </thead>
820
+ <tbody>
821
+ ${fileStats.map((f) => buildFilesBarsRow(f, maxLinesForBar)).join("")}
822
+ </tbody>
823
+ </table>
824
+ </section>
825
+
826
+ <footer class="footer">
827
+ <p>Generado por <strong>${APP_REPORT_TITLE}</strong> · ${timestamp}</p>
828
+ <p>Rama: <strong>${esc(currentBranch)}</strong> vs <strong>${esc(baseBranch)}</strong> · Score: <strong>${result.complexity.toFixed(2)}</strong> · Objetivo: <strong>${config.targetScore}</strong></p>
829
+ </footer>
830
+
831
+ </div>
832
+ </body>
833
+ </html>`;
834
+ }
835
+ /**
836
+ * Genera el reporte HTML de score puntual y lo escribe en disco.
837
+ *
838
+ * @param filePath - Ruta de destino del archivo HTML.
839
+ * @param input - Datos del análisis de score.
840
+ */
841
+ export function writeScoreHtmlReport(filePath, input) {
842
+ writeFileSync(filePath, renderScoreHtmlReport(input), "utf8");
843
+ }
709
844
  /**
710
845
  * Genera el JSON serializado del plan.
711
846
  * @param plans - Lista de planes de rama.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pull-request-split-advisor",
3
- "version": "3.1.2",
3
+ "version": "3.2.1",
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",
@@ -33,7 +33,7 @@
33
33
  ],
34
34
  "scripts": {
35
35
  "build:styles": "node scripts/compile-styles.cjs",
36
- "build": "npm run build:styles && tsc -p tsconfig.json",
36
+ "build": "npm run build:styles && tsc -p tsconfig.build.json",
37
37
  "prepare": "npm run build",
38
38
  "dev": "tsx src/cli.ts",
39
39
  "start": "node dist/cli.js",
@@ -83,30 +83,56 @@ async function main() {
83
83
  );
84
84
  }
85
85
 
86
- // ── Añadir la entrada al .gitignore del proyecto consumidor ─────────────
86
+ // ── Añadir entradas al .gitignore del proyecto consumidor ──────────────
87
87
  // La config es local por equipo/desarrollador y no debe versionarse.
88
- const gitignorePath = join(targetDir, ".gitignore");
89
- const gitignoreEntry = "pr-split-advisor.config.json";
88
+ // Los artefactos generados (reportes HTML, plan JSON, historial) tampoco.
89
+ const gitignorePath = join(targetDir, ".gitignore");
90
+
91
+ const gitignoreEntries = [
92
+ {
93
+ entry: "pr-split-advisor.config.json",
94
+ comment: "# pr-split-advisor — config local (no compartir en el repositorio)"
95
+ },
96
+ { entry: "pr-split-report.html", comment: null },
97
+ { entry: "pr-split-score.html", comment: null },
98
+ { entry: "pr-split-plan.json", comment: null },
99
+ { entry: ".pr-split-history.json", comment: null }
100
+ ];
101
+
102
+ // Cabecera del grupo de artefactos: solo se añade si al menos uno de ellos
103
+ // no está ya en el .gitignore, y solo una vez para todo el grupo.
104
+ const artifactEntries = ["pr-split-report.html", "pr-split-score.html", "pr-split-plan.json", ".pr-split-history.json"];
90
105
 
91
106
  try {
92
107
  const currentContent = existsSync(gitignorePath)
93
108
  ? readFileSync(gitignorePath, "utf-8")
94
109
  : "";
95
110
 
96
- const alreadyIgnored = currentContent
97
- .split("\n")
98
- .map((l) => l.trim())
99
- .includes(gitignoreEntry);
111
+ const existingLines = currentContent.split("\n").map((l) => l.trim());
112
+ let block = currentContent.length && !currentContent.endsWith("\n") ? "\n" : "";
113
+ let addedAny = false;
114
+ let artifactHeaderAdded = false;
115
+
116
+ for (const { entry, comment } of gitignoreEntries) {
117
+ if (existingLines.includes(entry)) continue;
100
118
 
101
- if (!alreadyIgnored) {
102
- const block =
103
- (currentContent.length && !currentContent.endsWith("\n") ? "\n" : "") +
104
- "\n# pr-split-advisor config local (no compartir en el repositorio)\n" +
105
- gitignoreEntry + "\n";
119
+ const isArtifact = artifactEntries.includes(entry);
120
+ if (isArtifact && !artifactHeaderAdded && !comment) {
121
+ // Escribir la cabecera de artefactos la primera vez que haya uno nuevo
122
+ block += "\n# pr-split-advisor \u2014 artefactos generados\n";
123
+ artifactHeaderAdded = true;
124
+ } else if (comment) {
125
+ block += "\n" + comment + "\n";
126
+ }
127
+
128
+ block += entry + "\n";
129
+ addedAny = true;
130
+ }
106
131
 
132
+ if (addedAny) {
107
133
  appendFileSync(gitignorePath, block, "utf-8");
108
134
  console.log(
109
- "[pr-split-advisor] ✔ Añadido pr-split-advisor.config.json al .gitignore"
135
+ "[pr-split-advisor] ✔ Añadidas entradas de pr-split-advisor al .gitignore"
110
136
  );
111
137
  }
112
138
  } catch (err) {