pull-request-split-advisor 3.2.0 → 3.2.2

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
@@ -29,6 +29,8 @@ La v3.0.0 también mejora el **apply**: rama de respaldo con snapshot completo d
29
29
 
30
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
31
 
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.
33
+
32
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.
33
35
 
34
36
  ---
@@ -40,7 +42,8 @@ A partir de la **v3.1.0**, el proyecto incluye una **suite completa de tests** (
40
42
  - Cálculo de score de calidad por rama (0–5) con factores ponderados
41
43
  - Generación de nombres de rama y mensajes de commit convencionales
42
44
  - 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
45
+ - **`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 — evalúa **solo archivos WIP y sin rastrear**; si el working tree está limpio, informa al usuario y sale sin generar un score incorrecto
46
+ - **Clasificación de origen por archivo** (v3.2.1+): diferencia entre `REMOTO` (publicado), `LOCAL` (commit local no publicado), `WIP` (sin commitear) y `NUEVO` (sin rastrear) — badge de color en terminal y columna *Origen* en ambos reportes HTML
44
47
  - Exportación del plan en formato JSON para integraciones externas
45
48
  - Historial de scores con seguimiento entre análisis
46
49
  - Validaciones de nomenclatura de ramas y estado del working tree
@@ -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
@@ -29,7 +29,8 @@ 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
31
  import { detectRemote, fetchQuiet, getCurrentBranch, localAheadCount, localBehindCount, remoteTrackingExists, requireCleanIndex, requireGitRepo } from "./git/git.js";
32
- import { buildBlocks, buildDependencyEdges, findBestPlan, gatherChangedFiles, getFileStats } from "./core/planner.js";
32
+ import { buildBlocks, buildDependencyEdges, computeFileOrigins, findBestPlan, gatherChangedFiles, getFileStats } from "./core/planner.js";
33
+ import { resetTrackedFilesCache } from "./shared/utils.js";
33
34
  import { exportJson, writeHtmlReport, writeScoreHtmlReport } from "./output/report.js";
34
35
  import { appendHistory, readHistory } from "./core/history.js";
35
36
  import { buildSuggestedMessage } from "./core/commit-planner.js";
@@ -129,9 +130,10 @@ function printSummary(fileStats, config, currentBranch, baseBranch) {
129
130
  }
130
131
  function printFileStats(fileStats) {
131
132
  ui.section("DETALLE DE ARCHIVOS", "#");
132
- ui.table(["Marca", "Archivo", "Lineas", "+", "-", "Prioridad"], fileStats.map((f) => [
133
+ ui.table(["Origen", "Marca", "Archivo", "Lineas", "+", "-", "Prioridad"], fileStats.map((f) => [
134
+ ui.originBadge(f.origin),
133
135
  changeTypeLabel(f.changeType),
134
- ui.truncateMiddle(f.path, 55),
136
+ ui.truncateMiddle(f.path, 50),
135
137
  f.lines,
136
138
  f.additions,
137
139
  f.deletions,
@@ -280,6 +282,7 @@ async function main() {
280
282
  config.jsonOutputFile,
281
283
  "pr-split-advisor.config.json",
282
284
  "pr-split-report.html",
285
+ "pr-split-score.html",
283
286
  config.historyFile
284
287
  ].filter(Boolean);
285
288
  if (config.verbose) {
@@ -349,7 +352,7 @@ async function main() {
349
352
  const changedFiles = gatherChangedFiles(config, baseBranch);
350
353
  if (!changedFiles.length) {
351
354
  ui.spinner.stop();
352
- ui.warn("No hay cambios sin commit en el working tree.");
355
+ ui.warn("No hay cambios respecto a la rama base.");
353
356
  closeReadlineInterface();
354
357
  return;
355
358
  }
@@ -364,6 +367,16 @@ async function main() {
364
367
  const plans = findBestPlan(blocks, currentBranch, config, deps);
365
368
  config.testCoveragePercent = computeTestCoveragePercent(fileStats);
366
369
  ui.spinner.stop("Analisis completado.");
370
+ // ─── Computar origen de cada archivo en el árbol git ─────────────────────
371
+ // Diferencia entre: ya publicado (REMOTO), commiteado local (LOCAL),
372
+ // cambio sin commitear (WIP) y archivo nuevo sin rastrear (NUEVO).
373
+ // Resetear la caché de tracked files para que computeFileOrigins use
374
+ // un Set fresco (evita stale data si el index cambió durante el análisis).
375
+ resetTrackedFilesCache();
376
+ const origins = computeFileOrigins(changedFiles, baseBranch, currentBranch, remote);
377
+ for (const stat of fileStats) {
378
+ stat.origin = origins.get(stat.path);
379
+ }
367
380
  // ─── Enriquecimiento con IA (opcional) ───────────────────────────────────
368
381
  // Si la IA no está habilitada o configurada, `aiEnrichPlans` retorna
369
382
  // de inmediato sin modificar los planes (modo heurístico puro).
@@ -439,6 +452,7 @@ async function main() {
439
452
  config.jsonOutputFile,
440
453
  "pr-split-advisor.config.json",
441
454
  "pr-split-report.html",
455
+ "pr-split-score.html",
442
456
  config.historyFile
443
457
  ].filter(Boolean);
444
458
  ui.spinner.start("Calculando score del estado actual...");
@@ -451,17 +465,43 @@ async function main() {
451
465
  }
452
466
  const fileStats = getFileStats(changedFiles, baseBranch);
453
467
  ui.spinner.stop("Calculo completado.");
468
+ // ─── Computar origen de cada archivo en el árbol git ─────────────────────
469
+ // Resetear caché para asegurar datos frescos (el score subcommand no llama
470
+ // a requireCleanIndex, por lo que puede haber archivos staged/unstaged que
471
+ // cambien el estado del index entre la primera lectura y esta clasificación).
472
+ resetTrackedFilesCache();
473
+ const scoreRemote = detectRemote(currentBranch);
474
+ const scoreOrigins = computeFileOrigins(changedFiles, baseBranch, currentBranch, scoreRemote);
475
+ for (const stat of fileStats) {
476
+ stat.origin = scoreOrigins.get(stat.path);
477
+ }
454
478
  const totalFiles = fileStats.length;
455
479
  const totalLines = fileStats.reduce((sum, f) => sum + f.lines, 0);
480
+ // El score se calcula ÚNICAMENTE sobre archivos del working tree en ese momento.
481
+ // Los archivos ya commiteados (LOCAL o REMOTO) se muestran en el reporte
482
+ // con su badge de origen pero NO afectan las métricas del score.
483
+ const scoredStats = fileStats.filter((f) => f.origin === "working-tree" || f.origin === "untracked");
484
+ const scoredFiles = scoredStats.length;
485
+ const scoredLines = scoredStats.reduce((sum, f) => sum + f.lines, 0);
486
+ // Si no hay archivos WIP no hay nada que puntuar: todos los cambios ya
487
+ // están en commits (LOCAL o REMOTO) y el working tree está limpio.
488
+ if (scoredFiles === 0) {
489
+ ui.warn("No hay cambios en el working tree para analizar. " +
490
+ "Todos los archivos detectados ya están en commits (LOCAL o REMOTO). " +
491
+ "Realiza cambios sin commitear para obtener un score.");
492
+ closeReadlineInterface();
493
+ return;
494
+ }
456
495
  const aheadCommits = localAheadCount(baseBranch);
457
496
  const commitCount = Math.max(aheadCommits, 1);
458
- const filesPerCommit = Math.round((totalFiles / commitCount) * 10) / 10;
459
- const avgLinesPerCommit = Math.round(totalLines / commitCount);
460
- const result = scorePullRequest({ commitCount, filesPerCommit, avgLinesPerCommit, totalLinesChanged: totalLines }, config);
497
+ const filesPerCommit = Number((scoredFiles / commitCount).toFixed(2));
498
+ const avgLinesPerCommit = Math.round(scoredLines / commitCount);
499
+ const result = scorePullRequest({ commitCount, filesPerCommit, avgLinesPerCommit, totalLinesChanged: scoredLines }, config);
500
+ const warnThreshold = config.targetScore > 4 ? 4 : Math.max(0, config.targetScore - 1);
461
501
  const scoreColor = ui.scoreColor(result.complexity, config.targetScore);
462
502
  const statusBadge = result.complexity >= config.targetScore
463
503
  ? ui.badge("ÓPTIMO", "green")
464
- : result.complexity >= config.targetScore - 1
504
+ : result.complexity >= warnThreshold
465
505
  ? ui.badge("ACEPTABLE", "yellow")
466
506
  : ui.badge("RIESGO", "red");
467
507
  const scoreHtmlFile = "pr-split-score.html";
@@ -473,18 +513,27 @@ async function main() {
473
513
  commitCount,
474
514
  filesPerCommit,
475
515
  avgLinesPerCommit,
476
- result
516
+ result,
517
+ scoredFileCount: scoredFiles !== totalFiles ? scoredFiles : undefined,
518
+ scoredLines: scoredFiles !== totalFiles ? scoredLines : undefined
477
519
  });
478
520
  ui.ok(`Reporte HTML generado: ${scoreHtmlFile}`);
521
+ const hasWipDistinction = scoredFiles !== totalFiles;
479
522
  ui.card(`Score actual de la rama: ${currentBranch}`, [
480
523
  `${statusBadge}`,
481
524
  "",
482
- `Rama base: ${baseBranch}`,
483
- `Archivos cambiados: ${totalFiles}`,
484
- `Líneas totales: ${totalLines}`,
485
- `Commits adelantados: ${commitCount}`,
486
- `Archivos por commit: ${filesPerCommit}`,
487
- `Líneas por commit (avg):${avgLinesPerCommit}`,
525
+ `Rama base: ${baseBranch}`,
526
+ `Archivos totales analizados: ${totalFiles}${hasWipDistinction ? ` (${totalFiles - scoredFiles} ya commiteados, excluidos del score)` : ""}`,
527
+ ...(hasWipDistinction
528
+ ? [`Archivos scored (WIP): ${scoredFiles} ← base del score`]
529
+ : []),
530
+ `Líneas totales analizadas: ${totalLines}`,
531
+ ...(hasWipDistinction
532
+ ? [`Líneas scored (WIP): ${scoredLines} ← base del score`]
533
+ : []),
534
+ `Commits adelantados: ${commitCount}`,
535
+ `Archivos / commit (scored): ${filesPerCommit}`,
536
+ `Líneas / commit (scored avg):${avgLinesPerCommit}`,
488
537
  "",
489
538
  `Score: ${ui.score(result.complexity, config.targetScore)}`,
490
539
  `Score objetivo: ${config.targetScore}`,
@@ -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`.
@@ -339,3 +339,80 @@ export function getFileStats(files, baseBranch) {
339
339
  };
340
340
  });
341
341
  }
342
+ // ---------------------------------------------------------------------------
343
+ // File origin classification
344
+ // ---------------------------------------------------------------------------
345
+ /**
346
+ * Clasifica cada archivo de la lista según su origen en el árbol git:
347
+ *
348
+ * - `"pushed"`: existe en commits ya publicados al remoto (base → remoto/rama).
349
+ * - `"local-commit"`: commiteado localmente pero aún no publicado (remoto/rama → HEAD,
350
+ * o base → HEAD cuando no hay ref de tracking remota).
351
+ * - `"working-tree"`: cambio staged o unstaged no commiteado en el working tree.
352
+ * - `"untracked"`: archivo nuevo sin seguimiento git (nunca añadido al índice).
353
+ *
354
+ * La asignación sigue un orden de prioridad:
355
+ * untracked > working-tree/staged > local-commit > pushed > fallback("working-tree")
356
+ *
357
+ * Cuando un archivo tiene cambios en el working tree además de estar en un commit
358
+ * ya publicado, se clasifica como `"working-tree"` porque tiene cambios pendientes
359
+ * por encima del commit remoto.
360
+ *
361
+ * @param files - Lista de rutas relativas a clasificar.
362
+ * @param baseBranch - Rama base del PR (ej. "main").
363
+ * @param currentBranch - Rama de trabajo actual.
364
+ * @param remote - Nombre del remoto (ej. "origin").
365
+ * @returns Mapa `ruta → FileOrigin` con una entrada por cada ruta de `files`.
366
+ */
367
+ export function computeFileOrigins(files, baseBranch, currentBranch, remote) {
368
+ const origins = new Map();
369
+ // Archivos con cambios unstaged en el working tree (tracked pero no staged)
370
+ const workingTreeSet = new Set(shSafe("git diff --name-only")
371
+ .split("\n").map((s) => s.trim()).filter(Boolean));
372
+ // Archivos staged (git add pero no commiteados) — también son "working-tree"
373
+ const stagedSet = new Set(shSafe("git diff --cached --name-only")
374
+ .split("\n").map((s) => s.trim()).filter(Boolean));
375
+ // Archivos untracked (nuevos, sin git add)
376
+ const untrackedSet = new Set(shSafe("git ls-files --others --exclude-standard")
377
+ .split("\n").map((s) => s.trim()).filter(Boolean));
378
+ // Verificar si existe una ref de tracking remota para la rama actual
379
+ const remoteRef = `${remote}/${currentBranch}`;
380
+ const remoteExists = shSafe(`git rev-parse --verify ${q(`refs/remotes/${remoteRef}`)}`).length > 0;
381
+ // Archivos en commits ya publicados (base → remoto/rama)
382
+ const pushedSet = new Set();
383
+ if (remoteExists) {
384
+ shSafe(`git diff ${q(baseBranch)}..${q(remoteRef)} --name-only`)
385
+ .split("\n").map((s) => s.trim()).filter(Boolean)
386
+ .forEach((f) => pushedSet.add(f));
387
+ }
388
+ // Archivos en commits locales aún no publicados
389
+ // Si existe tracking remota: commits entre remoto/rama y HEAD.
390
+ // Si no existe tracking: todos los commits adelantados respecto a base son locales.
391
+ const localCommitSet = new Set();
392
+ const localBaseRef = remoteExists ? remoteRef : baseBranch;
393
+ shSafe(`git diff ${q(localBaseRef)}..HEAD --name-only`)
394
+ .split("\n").map((s) => s.trim()).filter(Boolean)
395
+ .forEach((f) => localCommitSet.add(f));
396
+ // Asignar origen por prioridad: untracked > working-tree/staged > local-commit > pushed
397
+ for (const file of files) {
398
+ const norm = normalizePathValue(file);
399
+ if (untrackedSet.has(file) || untrackedSet.has(norm)) {
400
+ origins.set(file, "untracked");
401
+ }
402
+ else if (workingTreeSet.has(file) || workingTreeSet.has(norm) ||
403
+ stagedSet.has(file) || stagedSet.has(norm)) {
404
+ origins.set(file, "working-tree");
405
+ }
406
+ else if (localCommitSet.has(file) || localCommitSet.has(norm)) {
407
+ origins.set(file, "local-commit");
408
+ }
409
+ else if (pushedSet.has(file) || pushedSet.has(norm)) {
410
+ origins.set(file, "pushed");
411
+ }
412
+ else {
413
+ // Fallback: si el archivo no aparece en ningún diff conocido, asumir working-tree
414
+ origins.set(file, "working-tree");
415
+ }
416
+ }
417
+ return origins;
418
+ }
@@ -19,7 +19,7 @@
19
19
  * Importar desde este barrel en lugar de los módulos individuales reduce el
20
20
  * acoplamiento entre `cli.ts` y la estructura interna del directorio `core/`.
21
21
  */
22
- export { gatherChangedFiles, getFileStats } from "./file-stats.js";
22
+ export { gatherChangedFiles, getFileStats, computeFileOrigins } from "./file-stats.js";
23
23
  export { buildDependencyEdges } from "./dependency.js";
24
24
  export { buildBlocks } from "./blocks.js";
25
25
  export { findBestPlan } from "./strategy.js";
@@ -7,4 +7,4 @@
7
7
  * Para aplicar cambios editá `src/output/report.scss` y ejecutá:
8
8
  * npm run build:styles
9
9
  */
10
- export const reportStyles = `@import"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap";:root{--bg: #f0f4f8;--surface: #ffffff;--surface-soft: #f7f9fc;--border: #dde3ed;--text: #1e293b;--muted: #64748b;--green: #15803d;--green-soft: #dcfce7;--green-border: #86efac;--yellow: #a16207;--yellow-soft: #fef3c7;--yellow-border: #fde68a;--orange: #c2410c;--orange-soft: #ffedd5;--orange-border: #fdba74;--red: #b91c1c;--red-soft: #fee2e2;--red-border: #fca5a5;--blue: #1d4ed8;--blue-soft: #dbeafe;--blue-border: #93c5fd;--purple: #6d28d9;--purple-soft: #ede9fe;--shadow-sm: 0 1px 3px rgba(15, 23, 42, 0.07), 0 1px 2px rgba(15, 23, 42, 0.05);--shadow: 0 4px 16px rgba(15, 23, 42, 0.08), 0 1px 3px rgba(15, 23, 42, 0.05);--shadow-lg: 0 10px 40px rgba(15, 23, 42, 0.12), 0 2px 8px rgba(15, 23, 42, 0.06);--radius: 14px;--radius-sm: 8px}*,*::before,*::after{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;padding:0;background:var(--bg);color:var(--text);font-family:"Inter",-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;font-size:15px;line-height:1.6}.container{max-width:1320px;margin:0 auto;padding:32px 24px 80px}.hero{background:linear-gradient(135deg, #0f172a 0%, #1e3a6e 50%, #1d4ed8 100%);color:#fff;border-radius:24px;padding:36px 36px 28px;box-shadow:var(--shadow-lg);margin-bottom:28px;position:relative;overflow:hidden}.hero::before{content:"";position:absolute;inset:0;background:url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.03'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");pointer-events:none}.hero-top{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;flex-wrap:wrap}.hero-title-area h1{margin:0 0 6px;font-size:34px;font-weight:800;letter-spacing:-0.5px;line-height:1.1}.hero-title-area p{margin:0;color:hsla(0,0%,100%,.75);font-size:14px;max-width:500px}.hero-timestamp{background:hsla(0,0%,100%,.12);border:1px solid hsla(0,0%,100%,.2);border-radius:10px;padding:8px 14px;font-size:12px;color:hsla(0,0%,100%,.85);white-space:nowrap}.hero-grid{margin-top:24px;display:grid;grid-template-columns:repeat(auto-fit, minmax(200px, 1fr));gap:12px}.hero-item{background:hsla(0,0%,100%,.11);border:1px solid hsla(0,0%,100%,.16);border-radius:14px;padding:14px 18px;backdrop-filter:blur(4px);transition:background .2s}.hero-item:hover{background:hsla(0,0%,100%,.17)}.hero-item .label{font-size:11px;text-transform:uppercase;letter-spacing:.08em;opacity:.7;margin-bottom:5px}.hero-item .value{font-size:17px;font-weight:700;word-break:break-word}.disclaimer-banner{display:flex;align-items:flex-start;gap:16px;margin:24px 0 0;padding:20px 24px;background:#fefce8;border:2px solid #f59e0b;border-radius:12px;box-shadow:0 2px 8px rgba(245,158,11,.18)}.disclaimer-banner .disclaimer-icon{font-size:32px;line-height:1;flex-shrink:0;margin-top:2px}.disclaimer-banner .disclaimer-body{flex:1;color:#78350f}.disclaimer-banner .disclaimer-body strong:first-child{display:block;font-size:15px;font-weight:800;letter-spacing:.5px;text-transform:uppercase;color:#92400e;margin-bottom:8px}.disclaimer-banner .disclaimer-body p{margin:0 0 8px;font-size:14px;line-height:1.6}.disclaimer-banner .disclaimer-body p:last-child{margin-bottom:0}.disclaimer-banner .disclaimer-body ul{margin:6px 0 10px 18px;padding:0;font-size:14px;line-height:1.7}.disclaimer-banner .disclaimer-body .disclaimer-footer{font-size:12px;font-style:italic;color:#a16207;margin-top:8px}.section{margin-top:36px}.section-heading{display:flex;align-items:center;gap:10px;margin:0 0 16px;font-size:20px;font-weight:700;color:#0f172a}.section-heading::after{content:"";flex:1;height:1px;background:var(--border)}.summary-grid{display:grid;grid-template-columns:repeat(auto-fit, minmax(190px, 1fr));gap:12px}.stat-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:16px 18px;box-shadow:var(--shadow-sm);display:flex;flex-direction:column;gap:4px;transition:box-shadow .2s,transform .2s}.stat-card:hover{box-shadow:var(--shadow);transform:translateY(-1px)}.stat-card .label{font-size:12px;color:var(--muted);font-weight:500;text-transform:uppercase;letter-spacing:.04em}.stat-card .value{font-size:26px;font-weight:800;line-height:1.2}.stat-card .sub{font-size:12px;color:var(--muted)}.stat-card.blue .value{color:var(--blue)}.stat-card.green .value{color:var(--green)}.stat-card.yellow .value{color:var(--yellow)}.stat-card.red .value{color:var(--red)}.stat-card.purple .value{color:var(--purple)}.card{background:var(--surface);border:1px solid var(--border);border-radius:18px;padding:22px;box-shadow:var(--shadow);margin-bottom:20px}.badges{display:flex;flex-wrap:wrap;gap:8px}.badge{display:inline-flex;align-items:center;gap:5px;padding:5px 12px;border-radius:999px;font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;border:1px solid rgba(0,0,0,0)}.badge.green{background:var(--green-soft);color:var(--green);border-color:var(--green-border)}.badge.yellow{background:var(--yellow-soft);color:var(--yellow);border-color:var(--yellow-border)}.badge.orange{background:var(--orange-soft);color:var(--orange);border-color:var(--orange-border)}.badge.red{background:var(--red-soft);color:var(--red);border-color:var(--red-border)}.badge.blue{background:var(--blue-soft);color:var(--blue);border-color:var(--blue-border)}.badge.purple{background:var(--purple-soft);color:var(--purple);border-color:#c4b5fd}table{width:100%;border-collapse:collapse;background:var(--surface);border:1px solid var(--border);border-radius:14px;overflow:hidden;box-shadow:var(--shadow-sm);margin:10px 0 20px}table thead th{background:#f1f5fd;color:#374151;font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;text-align:left;padding:11px 14px;border-bottom:1px solid var(--border)}table tbody td{padding:10px 14px;border-bottom:1px solid #e9eff7;vertical-align:middle;font-size:13px}table tbody tr:last-child td{border-bottom:none}table tbody tr:nth-child(even){background:#fafbff}table tbody tr:hover{background:#f0f6ff}.num-cell{text-align:right;font-variant-numeric:tabular-nums;font-weight:600}code{background:#eef2ff;color:#312e81;padding:2px 7px;border-radius:6px;font-size:12px;font-family:"SF Mono","Fira Code","Cascadia Code",monospace;word-break:break-word}.muted{color:var(--muted);font-size:13px}.toc-box{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:16px 20px;box-shadow:var(--shadow-sm);margin-bottom:20px}.toc-title{font-size:13px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:10px}.toc-list{list-style:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:6px}.toc-link{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;border-radius:999px;font-size:12px;font-weight:600;text-decoration:none;border:1px solid rgba(0,0,0,0);transition:opacity .15s}.toc-link:hover{opacity:.8;text-decoration:none}.toc-ok{background:var(--green-soft);color:var(--green);border-color:var(--green-border)}.toc-warn{background:var(--yellow-soft);color:var(--yellow);border-color:var(--yellow-border)}.toc-bad{background:var(--red-soft);color:var(--red);border-color:var(--red-border)}.toc-score{background:rgba(0,0,0,.08);border-radius:999px;padding:1px 7px;font-size:11px;font-weight:800}.plan-card{border-radius:20px;overflow:hidden;box-shadow:var(--shadow);border:1px solid var(--border);margin-bottom:28px}.plan-card-header{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:16px;padding:22px 26px}.plan-card-body{background:var(--surface);padding:22px 26px}.plan-header-green{background:linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%);border-bottom:1px solid #86efac}.plan-header-yellow{background:linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%);border-bottom:1px solid #fde68a}.plan-header-red{background:linear-gradient(135deg, #fff1f2 0%, #fee2e2 100%);border-bottom:1px solid #fca5a5}.plan-header-left{display:flex;align-items:center;gap:14px}.plan-branch-icon{font-size:28px;width:48px;height:48px;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.06);border-radius:14px;flex-shrink:0}.plan-branch-name{font-size:17px;font-weight:800;color:#0f172a;word-break:break-all;display:flex;align-items:center;gap:8px;flex-wrap:wrap}.plan-branch-sub{font-size:13px;color:var(--muted);margin-top:2px}.existing-badge{display:inline-block;background:#dbeafe;color:#1e40af;border:1px solid #93c5fd;padding:1px 8px;border-radius:999px;font-size:11px;font-weight:700;margin-right:4px}.plan-header-right{display:flex;flex-direction:column;align-items:center;gap:4px}.gauge-svg{display:block}.plan-score-label{font-size:11px;font-weight:800;letter-spacing:.08em;text-transform:uppercase}.score-label-green{color:var(--green)}.score-label-yellow{color:var(--yellow)}.score-label-red{color:var(--red)}.kpi-row{display:flex;flex-wrap:wrap;gap:12px;margin:16px 0 24px}.kpi{background:var(--surface-soft);border:1px solid var(--border);border-radius:12px;padding:10px 16px;min-width:90px;text-align:center}.kpi-v{font-size:22px;font-weight:800;color:#0f172a;line-height:1.1}.kpi-k{font-size:11px;color:var(--muted);margin-top:3px;text-transform:uppercase;letter-spacing:.04em}.recommendation-box{display:flex;align-items:flex-start;gap:10px;background:#f0f9ff;border:1px solid #bae6fd;border-radius:12px;padding:12px 16px;margin-bottom:16px;font-size:14px;color:#0369a1}.exclusion-note{display:flex;align-items:flex-start;gap:8px;background:#fffbeb;border:1px solid #fde68a;border-radius:10px;padding:10px 14px;margin-bottom:16px;font-size:13px;color:#92400e}.rec-icon{font-size:16px;flex-shrink:0}.section-title{font-size:14px;font-weight:700;color:#0f172a;margin:28px 0 10px;display:flex;align-items:center;gap:6px}.metrics-table thead th{background:#f8faff}.metric-code{background:#e0e7ff;color:#3730a3;font-weight:700}.metric-label{font-size:13px}.metric-value{font-weight:600;color:#374151}.metric-weight{font-weight:600;color:var(--muted)}.metric-contrib{font-weight:700;color:#0f172a;font-variant-numeric:tabular-nums}.pts-bar-wrap{height:6px;background:#e2e8f0;border-radius:999px;overflow:hidden;width:80px;display:inline-block;vertical-align:middle;margin-right:6px}.pts-bar{height:100%;border-radius:999px;transition:width .4s ease}.pts-good{color:var(--green);font-weight:700}.pts-warn{color:var(--yellow);font-weight:700}.pts-bad{color:var(--red);font-weight:700}.metrics-total-row td{background:#f8faff;font-size:13px}.metric-total-value{font-size:16px;font-weight:800;color:#0f172a}.ct-badge{display:inline-block;padding:3px 10px;border-radius:999px;font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase}.ct-added{background:#dcfce7;color:#15803d;border:1px solid #86efac}.ct-untracked{background:#ede9fe;color:#6d28d9;border:1px solid #c4b5fd}.ct-modified{background:#dbeafe;color:#1d4ed8;border:1px solid #93c5fd}.ct-deleted{background:#fee2e2;color:#b91c1c;border:1px solid #fca5a5}.ct-renamed{background:#fef3c7;color:#a16207;border:1px solid #fde68a}.ct-default{background:#f1f5f9;color:#475569}.ct-feat{background:#dbeafe;color:#1d4ed8}.ct-fix{background:#fee2e2;color:#b91c1c}.ct-chore{background:#f1f5f9;color:#475569}.ct-docs{background:#fef3c7;color:#a16207}.ct-test{background:#ede9fe;color:#6d28d9}.ct-refactor{background:#f0fdfa;color:#0f766e}.ct-style{background:#fdf4ff;color:#9333ea}.ct-perf{background:#fff7ed;color:#c2410c}.file-path code{max-width:380px;display:inline-block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.diff-bar{display:inline-flex;height:8px;border-radius:4px;overflow:hidden;background:#e2e8f0;vertical-align:middle;min-width:4px}.diff-add{background:#4ade80;height:100%}.diff-del{background:#f87171;height:100%}.diff-labels{font-size:11px;margin-left:6px;font-variant-numeric:tabular-nums}.add-lbl{color:var(--green);font-weight:700}.del-lbl{color:var(--red);font-weight:700;margin-left:3px}.prio-dot{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:999px;font-size:12px;font-weight:800}.prio-1{background:#fee2e2;color:#b91c1c}.prio-2{background:#ffedd5;color:#c2410c}.prio-3{background:#fef3c7;color:#a16207}.prio-4{background:#dbeafe;color:#1d4ed8}.prio-5{background:#f1f5f9;color:#475569}.divisible-yes{color:var(--green);font-size:13px;font-weight:700}.divisible-no{color:var(--red);font-size:13px;font-weight:700}.block-id{background:#f1f5f9;color:#334155;font-size:11px}.block-files code{font-size:10px;margin:1px}.timeline{position:relative;padding-left:28px}.timeline::before{content:"";position:absolute;left:9px;top:16px;bottom:16px;width:2px;background:#e2e8f0;border-radius:999px}.timeline-item{position:relative;margin-bottom:20px}.timeline-dot{position:absolute;left:-24px;top:14px;width:12px;height:12px;background:#3b82f6;border:2px solid #fff;border-radius:999px;box-shadow:0 0 0 2px #bfdbfe}.timeline-body{background:var(--surface-soft);border:1px solid var(--border);border-radius:12px;padding:12px 16px}.timeline-header{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-bottom:6px}.commit-index{background:#e2e8f0;color:#475569;padding:2px 8px;border-radius:999px;font-size:11px;font-weight:800}.commit-type-badge{padding:3px 10px;border-radius:999px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.04em}.commit-msg{font-size:13px;color:#0f172a;background:#f8fafc;border:1px solid #e2e8f0;padding:2px 8px;border-radius:6px;word-break:break-word}.timeline-meta{display:flex;flex-wrap:wrap;gap:12px;font-size:12px;color:var(--muted);margin-bottom:8px}.timeline-meta span{display:flex;align-items:center;gap:3px}.commit-files-list{margin:0;padding-left:16px;list-style:disc}.commit-files-list li{margin:2px 0;font-size:12px}.commit-files-list code{font-size:11px;padding:1px 5px}.dep-arrow{color:var(--muted);font-size:16px;vertical-align:middle}.footer{margin-top:48px;border-top:1px solid var(--border);padding-top:20px;text-align:center;font-size:12px;color:var(--muted)}.copy-btn{background:none;border:1px solid rgba(0,0,0,.12);border-radius:6px;padding:1px 6px;cursor:pointer;font-size:11px;color:#64748b;line-height:1.4;transition:background .15s,color .15s;flex-shrink:0;vertical-align:middle}.copy-btn:hover{background:#f1f5f9;color:#0f172a;border-color:#94a3b8}.commit-type-pills{display:flex;flex-wrap:wrap;gap:4px;margin-top:4px}.commit-type-pill{display:inline-block;padding:1px 8px;border-radius:999px;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.03em;opacity:.85}.toc-bar-wrap{width:50px;height:4px;background:rgba(0,0,0,.1);border-radius:999px;overflow:hidden;flex-shrink:0}.toc-bar{height:100%;border-radius:999px;transition:width .4s ease}.toc-bar-ok{background:var(--green)}.toc-bar-warn{background:var(--yellow)}.toc-bar-bad{background:var(--red)}.toc-commits{font-size:11px;opacity:.65;font-weight:500;white-space:nowrap}.back-to-top{text-align:right;padding-top:12px;border-top:1px solid var(--border);margin-top:20px}.back-link{font-size:12px;color:var(--muted);text-decoration:none;font-weight:600}.back-link:hover{color:#3b82f6;text-decoration:underline}.git-commands-block{background:#0f172a;border-radius:14px;padding:18px 20px;overflow-x:auto;margin-top:8px;box-shadow:inset 0 1px 4px rgba(0,0,0,.3)}.git-commands-block pre{margin:0;font-family:"JetBrains Mono","Fira Mono",ui-monospace,monospace;font-size:12px;line-height:1.8;color:#e2e8f0;white-space:pre}.git-commands-block .gc-comment{color:#4b6988;font-style:italic}.git-commands-block .gc-cmd{color:#7dd3fc;font-weight:700}.git-commands-block .gc-branch{color:#86efac}.git-commands-block .gc-file{color:#fca5a5}.git-commands-block .gc-msg{color:#fde68a}.ct-ci{background:#ecfdf5;color:#059669}.ct-revert{background:#fdf4ff;color:#c026d3}@media(max-width: 960px){.hero{padding:24px 20px}.hero-title-area h1{font-size:26px}.plan-card-header{padding:18px}.plan-card-body{padding:18px}.file-path code{max-width:200px}}@media(max-width: 640px){.container{padding:16px 12px 60px}.kpi-row{gap:8px}.kpi{padding:8px 12px;min-width:72px}.kpi-v{font-size:18px}}@media print{body{background:#fff;font-size:12px}.container{max-width:100%;padding:0}.hero{border-radius:0;box-shadow:none}.plan-card,.card,.stat-card,table{box-shadow:none;break-inside:avoid}.section{break-before:auto}a{color:inherit;text-decoration:none}}`;
10
+ export const reportStyles = `@import"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap";:root{--bg: #f0f4f8;--surface: #ffffff;--surface-soft: #f7f9fc;--border: #dde3ed;--text: #1e293b;--muted: #64748b;--green: #15803d;--green-soft: #dcfce7;--green-border: #86efac;--yellow: #a16207;--yellow-soft: #fef3c7;--yellow-border: #fde68a;--orange: #c2410c;--orange-soft: #ffedd5;--orange-border: #fdba74;--red: #b91c1c;--red-soft: #fee2e2;--red-border: #fca5a5;--blue: #1d4ed8;--blue-soft: #dbeafe;--blue-border: #93c5fd;--purple: #6d28d9;--purple-soft: #ede9fe;--shadow-sm: 0 1px 3px rgba(15, 23, 42, 0.07), 0 1px 2px rgba(15, 23, 42, 0.05);--shadow: 0 4px 16px rgba(15, 23, 42, 0.08), 0 1px 3px rgba(15, 23, 42, 0.05);--shadow-lg: 0 10px 40px rgba(15, 23, 42, 0.12), 0 2px 8px rgba(15, 23, 42, 0.06);--radius: 14px;--radius-sm: 8px}*,*::before,*::after{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;padding:0;background:var(--bg);color:var(--text);font-family:"Inter",-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;font-size:15px;line-height:1.6}.container{max-width:1320px;margin:0 auto;padding:32px 24px 80px}.hero{background:linear-gradient(135deg, #0f172a 0%, #1e3a6e 50%, #1d4ed8 100%);color:#fff;border-radius:24px;padding:36px 36px 28px;box-shadow:var(--shadow-lg);margin-bottom:28px;position:relative;overflow:hidden}.hero::before{content:"";position:absolute;inset:0;background:url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.03'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");pointer-events:none}.hero-top{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;flex-wrap:wrap}.hero-title-area h1{margin:0 0 6px;font-size:34px;font-weight:800;letter-spacing:-0.5px;line-height:1.1}.hero-title-area p{margin:0;color:hsla(0,0%,100%,.75);font-size:14px;max-width:500px}.hero-timestamp{background:hsla(0,0%,100%,.12);border:1px solid hsla(0,0%,100%,.2);border-radius:10px;padding:8px 14px;font-size:12px;color:hsla(0,0%,100%,.85);white-space:nowrap}.hero-grid{margin-top:24px;display:grid;grid-template-columns:repeat(auto-fit, minmax(200px, 1fr));gap:12px}.hero-item{background:hsla(0,0%,100%,.11);border:1px solid hsla(0,0%,100%,.16);border-radius:14px;padding:14px 18px;backdrop-filter:blur(4px);transition:background .2s}.hero-item:hover{background:hsla(0,0%,100%,.17)}.hero-item .label{font-size:11px;text-transform:uppercase;letter-spacing:.08em;opacity:.7;margin-bottom:5px}.hero-item .value{font-size:17px;font-weight:700;word-break:break-word}.disclaimer-banner{display:flex;align-items:flex-start;gap:16px;margin:24px 0 0;padding:20px 24px;background:#fefce8;border:2px solid #f59e0b;border-radius:12px;box-shadow:0 2px 8px rgba(245,158,11,.18)}.disclaimer-banner .disclaimer-icon{font-size:32px;line-height:1;flex-shrink:0;margin-top:2px}.disclaimer-banner .disclaimer-body{flex:1;color:#78350f}.disclaimer-banner .disclaimer-body strong:first-child{display:block;font-size:15px;font-weight:800;letter-spacing:.5px;text-transform:uppercase;color:#92400e;margin-bottom:8px}.disclaimer-banner .disclaimer-body p{margin:0 0 8px;font-size:14px;line-height:1.6}.disclaimer-banner .disclaimer-body p:last-child{margin-bottom:0}.disclaimer-banner .disclaimer-body ul{margin:6px 0 10px 18px;padding:0;font-size:14px;line-height:1.7}.disclaimer-banner .disclaimer-body .disclaimer-footer{font-size:12px;font-style:italic;color:#a16207;margin-top:8px}.section{margin-top:36px}.section-heading{display:flex;align-items:center;gap:10px;margin:0 0 16px;font-size:20px;font-weight:700;color:#0f172a}.section-heading::after{content:"";flex:1;height:1px;background:var(--border)}.summary-grid{display:grid;grid-template-columns:repeat(auto-fit, minmax(190px, 1fr));gap:12px}.stat-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:16px 18px;box-shadow:var(--shadow-sm);display:flex;flex-direction:column;gap:4px;transition:box-shadow .2s,transform .2s}.stat-card:hover{box-shadow:var(--shadow);transform:translateY(-1px)}.stat-card .label{font-size:12px;color:var(--muted);font-weight:500;text-transform:uppercase;letter-spacing:.04em}.stat-card .value{font-size:26px;font-weight:800;line-height:1.2}.stat-card .sub{font-size:12px;color:var(--muted)}.stat-card.blue .value{color:var(--blue)}.stat-card.green .value{color:var(--green)}.stat-card.yellow .value{color:var(--yellow)}.stat-card.red .value{color:var(--red)}.stat-card.purple .value{color:var(--purple)}.card{background:var(--surface);border:1px solid var(--border);border-radius:18px;padding:22px;box-shadow:var(--shadow);margin-bottom:20px}.badges{display:flex;flex-wrap:wrap;gap:8px}.badge{display:inline-flex;align-items:center;gap:5px;padding:5px 12px;border-radius:999px;font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;border:1px solid rgba(0,0,0,0)}.badge.green{background:var(--green-soft);color:var(--green);border-color:var(--green-border)}.badge.yellow{background:var(--yellow-soft);color:var(--yellow);border-color:var(--yellow-border)}.badge.orange{background:var(--orange-soft);color:var(--orange);border-color:var(--orange-border)}.badge.red{background:var(--red-soft);color:var(--red);border-color:var(--red-border)}.badge.blue{background:var(--blue-soft);color:var(--blue);border-color:var(--blue-border)}.badge.purple{background:var(--purple-soft);color:var(--purple);border-color:#c4b5fd}table{width:100%;border-collapse:collapse;background:var(--surface);border:1px solid var(--border);border-radius:14px;overflow:hidden;box-shadow:var(--shadow-sm);margin:10px 0 20px}table thead th{background:#f1f5fd;color:#374151;font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;text-align:left;padding:11px 14px;border-bottom:1px solid var(--border)}table tbody td{padding:10px 14px;border-bottom:1px solid #e9eff7;vertical-align:middle;font-size:13px}table tbody tr:last-child td{border-bottom:none}table tbody tr:nth-child(even){background:#fafbff}table tbody tr:hover{background:#f0f6ff}.num-cell{text-align:right;font-variant-numeric:tabular-nums;font-weight:600}code{background:#eef2ff;color:#312e81;padding:2px 7px;border-radius:6px;font-size:12px;font-family:"SF Mono","Fira Code","Cascadia Code",monospace;word-break:break-word}.muted{color:var(--muted);font-size:13px}.toc-box{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:16px 20px;box-shadow:var(--shadow-sm);margin-bottom:20px}.toc-title{font-size:13px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:10px}.toc-list{list-style:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:6px}.toc-link{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;border-radius:999px;font-size:12px;font-weight:600;text-decoration:none;border:1px solid rgba(0,0,0,0);transition:opacity .15s}.toc-link:hover{opacity:.8;text-decoration:none}.toc-ok{background:var(--green-soft);color:var(--green);border-color:var(--green-border)}.toc-warn{background:var(--yellow-soft);color:var(--yellow);border-color:var(--yellow-border)}.toc-bad{background:var(--red-soft);color:var(--red);border-color:var(--red-border)}.toc-score{background:rgba(0,0,0,.08);border-radius:999px;padding:1px 7px;font-size:11px;font-weight:800}.plan-card{border-radius:20px;overflow:hidden;box-shadow:var(--shadow);border:1px solid var(--border);margin-bottom:28px}.plan-card-header{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:16px;padding:22px 26px}.plan-card-body{background:var(--surface);padding:22px 26px}.plan-header-green{background:linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%);border-bottom:1px solid #86efac}.plan-header-yellow{background:linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%);border-bottom:1px solid #fde68a}.plan-header-red{background:linear-gradient(135deg, #fff1f2 0%, #fee2e2 100%);border-bottom:1px solid #fca5a5}.plan-header-left{display:flex;align-items:center;gap:14px}.plan-branch-icon{font-size:28px;width:48px;height:48px;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.06);border-radius:14px;flex-shrink:0}.plan-branch-name{font-size:17px;font-weight:800;color:#0f172a;word-break:break-all;display:flex;align-items:center;gap:8px;flex-wrap:wrap}.plan-branch-sub{font-size:13px;color:var(--muted);margin-top:2px}.existing-badge{display:inline-block;background:#dbeafe;color:#1e40af;border:1px solid #93c5fd;padding:1px 8px;border-radius:999px;font-size:11px;font-weight:700;margin-right:4px}.plan-header-right{display:flex;flex-direction:column;align-items:center;gap:4px}.gauge-svg{display:block}.plan-score-label{font-size:11px;font-weight:800;letter-spacing:.08em;text-transform:uppercase}.score-label-green{color:var(--green)}.score-label-yellow{color:var(--yellow)}.score-label-red{color:var(--red)}.kpi-row{display:flex;flex-wrap:wrap;gap:12px;margin:16px 0 24px}.kpi{background:var(--surface-soft);border:1px solid var(--border);border-radius:12px;padding:10px 16px;min-width:90px;text-align:center}.kpi-v{font-size:22px;font-weight:800;color:#0f172a;line-height:1.1}.kpi-k{font-size:11px;color:var(--muted);margin-top:3px;text-transform:uppercase;letter-spacing:.04em}.recommendation-box{display:flex;align-items:flex-start;gap:10px;background:#f0f9ff;border:1px solid #bae6fd;border-radius:12px;padding:12px 16px;margin-bottom:16px;font-size:14px;color:#0369a1}.exclusion-note{display:flex;align-items:flex-start;gap:8px;background:#fffbeb;border:1px solid #fde68a;border-radius:10px;padding:10px 14px;margin-bottom:16px;font-size:13px;color:#92400e}.rec-icon{font-size:16px;flex-shrink:0}.section-title{font-size:14px;font-weight:700;color:#0f172a;margin:28px 0 10px;display:flex;align-items:center;gap:6px}.metrics-table thead th{background:#f8faff}.metric-code{background:#e0e7ff;color:#3730a3;font-weight:700}.metric-label{font-size:13px}.metric-value{font-weight:600;color:#374151}.metric-weight{font-weight:600;color:var(--muted)}.metric-contrib{font-weight:700;color:#0f172a;font-variant-numeric:tabular-nums}.pts-bar-wrap{height:6px;background:#e2e8f0;border-radius:999px;overflow:hidden;width:80px;display:inline-block;vertical-align:middle;margin-right:6px}.pts-bar{height:100%;border-radius:999px;transition:width .4s ease}.pts-good{color:var(--green);font-weight:700}.pts-warn{color:var(--yellow);font-weight:700}.pts-bad{color:var(--red);font-weight:700}.metrics-total-row td{background:#f8faff;font-size:13px}.metric-total-value{font-size:16px;font-weight:800;color:#0f172a}.ct-badge{display:inline-block;padding:3px 10px;border-radius:999px;font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase}.ct-added{background:#dcfce7;color:#15803d;border:1px solid #86efac}.ct-untracked{background:#ede9fe;color:#6d28d9;border:1px solid #c4b5fd}.ct-modified{background:#dbeafe;color:#1d4ed8;border:1px solid #93c5fd}.ct-deleted{background:#fee2e2;color:#b91c1c;border:1px solid #fca5a5}.ct-renamed{background:#fef3c7;color:#a16207;border:1px solid #fde68a}.ct-default{background:#f1f5f9;color:#475569}.ct-feat{background:#dbeafe;color:#1d4ed8}.ct-fix{background:#fee2e2;color:#b91c1c}.ct-chore{background:#f1f5f9;color:#475569}.ct-docs{background:#fef3c7;color:#a16207}.ct-test{background:#ede9fe;color:#6d28d9}.ct-refactor{background:#f0fdfa;color:#0f766e}.ct-style{background:#fdf4ff;color:#9333ea}.ct-perf{background:#fff7ed;color:#c2410c}.file-path code{max-width:380px;display:inline-block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.diff-bar{display:inline-flex;height:8px;border-radius:4px;overflow:hidden;background:#e2e8f0;vertical-align:middle;min-width:4px}.diff-add{background:#4ade80;height:100%}.diff-del{background:#f87171;height:100%}.diff-labels{font-size:11px;margin-left:6px;font-variant-numeric:tabular-nums}.add-lbl{color:var(--green);font-weight:700}.del-lbl{color:var(--red);font-weight:700;margin-left:3px}.prio-dot{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:999px;font-size:12px;font-weight:800}.prio-1{background:#fee2e2;color:#b91c1c}.prio-2{background:#ffedd5;color:#c2410c}.prio-3{background:#fef3c7;color:#a16207}.prio-4{background:#dbeafe;color:#1d4ed8}.prio-5{background:#f1f5f9;color:#475569}.divisible-yes{color:var(--green);font-size:13px;font-weight:700}.divisible-no{color:var(--red);font-size:13px;font-weight:700}.block-id{background:#f1f5f9;color:#334155;font-size:11px}.block-files code{font-size:10px;margin:1px}.timeline{position:relative;padding-left:28px}.timeline::before{content:"";position:absolute;left:9px;top:16px;bottom:16px;width:2px;background:#e2e8f0;border-radius:999px}.timeline-item{position:relative;margin-bottom:20px}.timeline-dot{position:absolute;left:-24px;top:14px;width:12px;height:12px;background:#3b82f6;border:2px solid #fff;border-radius:999px;box-shadow:0 0 0 2px #bfdbfe}.timeline-body{background:var(--surface-soft);border:1px solid var(--border);border-radius:12px;padding:12px 16px}.timeline-header{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-bottom:6px}.commit-index{background:#e2e8f0;color:#475569;padding:2px 8px;border-radius:999px;font-size:11px;font-weight:800}.commit-type-badge{padding:3px 10px;border-radius:999px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.04em}.commit-msg{font-size:13px;color:#0f172a;background:#f8fafc;border:1px solid #e2e8f0;padding:2px 8px;border-radius:6px;word-break:break-word}.timeline-meta{display:flex;flex-wrap:wrap;gap:12px;font-size:12px;color:var(--muted);margin-bottom:8px}.timeline-meta span{display:flex;align-items:center;gap:3px}.commit-files-list{margin:0;padding-left:16px;list-style:disc}.commit-files-list li{margin:2px 0;font-size:12px}.commit-files-list code{font-size:11px;padding:1px 5px}.dep-arrow{color:var(--muted);font-size:16px;vertical-align:middle}.footer{margin-top:48px;border-top:1px solid var(--border);padding-top:20px;text-align:center;font-size:12px;color:var(--muted)}.copy-btn{background:none;border:1px solid rgba(0,0,0,.12);border-radius:6px;padding:1px 6px;cursor:pointer;font-size:11px;color:#64748b;line-height:1.4;transition:background .15s,color .15s;flex-shrink:0;vertical-align:middle}.copy-btn:hover{background:#f1f5f9;color:#0f172a;border-color:#94a3b8}.commit-type-pills{display:flex;flex-wrap:wrap;gap:4px;margin-top:4px}.commit-type-pill{display:inline-block;padding:1px 8px;border-radius:999px;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.03em;opacity:.85}.toc-bar-wrap{width:50px;height:4px;background:rgba(0,0,0,.1);border-radius:999px;overflow:hidden;flex-shrink:0}.toc-bar{height:100%;border-radius:999px;transition:width .4s ease}.toc-bar-ok{background:var(--green)}.toc-bar-warn{background:var(--yellow)}.toc-bar-bad{background:var(--red)}.toc-commits{font-size:11px;opacity:.65;font-weight:500;white-space:nowrap}.back-to-top{text-align:right;padding-top:12px;border-top:1px solid var(--border);margin-top:20px}.back-link{font-size:12px;color:var(--muted);text-decoration:none;font-weight:600}.back-link:hover{color:#3b82f6;text-decoration:underline}.git-commands-block{background:#0f172a;border-radius:14px;padding:18px 20px;overflow-x:auto;margin-top:8px;box-shadow:inset 0 1px 4px rgba(0,0,0,.3)}.git-commands-block pre{margin:0;font-family:"JetBrains Mono","Fira Mono",ui-monospace,monospace;font-size:12px;line-height:1.8;color:#e2e8f0;white-space:pre}.git-commands-block .gc-comment{color:#4b6988;font-style:italic}.git-commands-block .gc-cmd{color:#7dd3fc;font-weight:700}.git-commands-block .gc-branch{color:#86efac}.git-commands-block .gc-file{color:#fca5a5}.git-commands-block .gc-msg{color:#fde68a}.ct-ci{background:#ecfdf5;color:#059669}.ct-revert{background:#fdf4ff;color:#c026d3}.origin-badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:9999px;font-size:10px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;white-space:nowrap;border:1px solid rgba(0,0,0,0)}.origin-pushed{background:#cffafe;color:#0e7490;border-color:#67e8f9}.origin-local-commit{background:#ede9fe;color:#6d28d9;border-color:#c4b5fd}.origin-working-tree{background:#fef3c7;color:#a16207;border-color:#fde68a}.origin-untracked{background:#f1f5f9;color:#475569;border-color:#cbd5e1}@media(max-width: 960px){.hero{padding:24px 20px}.hero-title-area h1{font-size:26px}.plan-card-header{padding:18px}.plan-card-body{padding:18px}.file-path code{max-width:200px}}@media(max-width: 640px){.container{padding:16px 12px 60px}.kpi-row{gap:8px}.kpi{padding:8px 12px;min-width:72px}.kpi-v{font-size:18px}}@media print{body{background:#fff;font-size:12px}.container{max-width:100%;padding:0}.hero{border-radius:0;box-shadow:none}.plan-card,.card,.stat-card,table{box-shadow:none;break-inside:avoid}.section{break-before:auto}a{color:inherit;text-decoration:none}}`;
@@ -153,6 +153,19 @@ function buildMetricsBreakdownTable(plan, config) {
153
153
  </tbody>
154
154
  </table>`;
155
155
  }
156
+ /**
157
+ * Devuelve el badge HTML para el origen de un archivo en el árbol git.
158
+ *
159
+ * @param origin - Valor de `FileOrigin` (o `undefined` → trata como `"working-tree"`).
160
+ */
161
+ function originHtmlBadge(origin) {
162
+ const cls = `origin-badge origin-${origin ?? "working-tree"}`;
163
+ const label = origin === "pushed" ? "REMOTO"
164
+ : origin === "local-commit" ? "LOCAL"
165
+ : origin === "untracked" ? "NUEVO"
166
+ : "WIP";
167
+ return `<span class="${esc(cls)}">${label}</span>`;
168
+ }
156
169
  /**
157
170
  * Genera una fila `<tr>` para la tabla de archivos con mini-barras visuales
158
171
  * proporcionales al máximo de líneas del conjunto.
@@ -173,6 +186,7 @@ function buildFilesBarsRow(f, maxLines) {
173
186
  const addPct = f.lines > 0 ? Math.round((f.additions / f.lines) * totalBar) : 0;
174
187
  const delPct = totalBar - addPct;
175
188
  return `<tr>
189
+ <td>${originHtmlBadge(f.origin)}</td>
176
190
  <td><span class="ct-badge ${ctClass}">${esc(changeTypeLabel(f.changeType))}</span></td>
177
191
  <td class="file-path"><code>${esc(f.path)}</code></td>
178
192
  <td class="num-cell">${f.lines}</td>
@@ -575,6 +589,7 @@ export function renderHtmlReport(input) {
575
589
  <table>
576
590
  <thead>
577
591
  <tr>
592
+ <th>Origen</th>
578
593
  <th>Tipo</th>
579
594
  <th>Archivo</th>
580
595
  <th>Líneas</th>
@@ -764,7 +779,10 @@ export function renderScoreHtmlReport(input) {
764
779
  <div class="hero-item"><div class="label">Rama base</div><div class="value">⎇ ${esc(baseBranch)}</div></div>
765
780
  <div class="hero-item"><div class="label">Score actual</div><div class="value">${result.complexity.toFixed(2)}</div></div>
766
781
  <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>
782
+ <div class="hero-item">
783
+ <div class="label">${input.scoredFileCount !== undefined && input.scoredFileCount !== totalFiles ? "Archivos scored / total" : "Archivos"}</div>
784
+ <div class="value">${input.scoredFileCount !== undefined && input.scoredFileCount !== totalFiles ? `${input.scoredFileCount} / ${totalFiles}` : totalFiles}</div>
785
+ </div>
768
786
  <div class="hero-item"><div class="label">Líneas totales</div><div class="value">${totalLines}</div></div>
769
787
  <div class="hero-item"><div class="label">Commits adelantados</div><div class="value">${commitCount}</div></div>
770
788
  <div class="hero-item"><div class="label">Estado</div><div class="value score-label-${scoreClass}">${esc(label)}</div></div>
@@ -773,6 +791,12 @@ export function renderScoreHtmlReport(input) {
773
791
 
774
792
  <section class="section">
775
793
  <h2 class="section-heading">🎯 Score</h2>
794
+ ${input.scoredFileCount !== undefined && input.scoredFileCount !== totalFiles
795
+ ? `<div class="recommendation-box" style="margin-bottom:16px">
796
+ <span class="rec-icon">ℹ️</span>
797
+ <span>Score calculado sobre <strong>${input.scoredFileCount} archivos del working tree</strong> (cambios no commiteados) de <strong>${totalFiles} archivos</strong> analizados. Los archivos <span class="origin-badge origin-pushed">REMOTO</span> y <span class="origin-badge origin-local-commit">LOCAL</span> se muestran en la tabla pero <strong>no afectan el score</strong>.</span>
798
+ </div>`
799
+ : ""}
776
800
  <div class="card" style="display:flex;align-items:center;gap:32px;padding:24px">
777
801
  ${buildScoreGaugeSvg(result.complexity, config.targetScore)}
778
802
  <div>
@@ -803,9 +827,10 @@ export function renderScoreHtmlReport(input) {
803
827
  <h2 class="section-heading">📈 Valores brutos usados</h2>
804
828
  <div class="summary-grid">
805
829
  <div class="stat-card blue"><div class="label">Commits</div><div class="value">${commitCount}</div></div>
830
+ <div class="stat-card blue"><div class="label">Archivos scored (WIP)</div><div class="value">${input.scoredFileCount ?? totalFiles}</div></div>
806
831
  <div class="stat-card blue"><div class="label">Archivos / commit</div><div class="value">${filesPerCommit}</div></div>
832
+ <div class="stat-card blue"><div class="label">Líneas scored (WIP)</div><div class="value">${input.scoredLines ?? totalLines}</div></div>
807
833
  <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
834
  <div class="stat-card green"><div class="label">Líneas agregadas</div><div class="value">+${totalAdditions}</div></div>
810
835
  <div class="stat-card red"><div class="label">Líneas eliminadas</div><div class="value">-${totalDeletions}</div></div>
811
836
  </div>
@@ -815,7 +840,7 @@ export function renderScoreHtmlReport(input) {
815
840
  <h2 class="section-heading">📄 Detalle de archivos</h2>
816
841
  <table>
817
842
  <thead>
818
- <tr><th>Tipo</th><th>Archivo</th><th>Líneas</th><th>Cambios (+/-)</th><th>Prioridad</th></tr>
843
+ <tr><th>Origen</th><th>Tipo</th><th>Archivo</th><th>Líneas</th><th>Cambios (+/-)</th><th>Prioridad</th></tr>
819
844
  </thead>
820
845
  <tbody>
821
846
  ${fileStats.map((f) => buildFilesBarsRow(f, maxLinesForBar)).join("")}
package/dist/output/ui.js CHANGED
@@ -338,8 +338,7 @@ function summaryCard(title, items) {
338
338
  });
339
339
  card(title, lines, "cyan");
340
340
  }
341
- /**
342
- * Genera la representación visual de un bloque para tablas y listas.
341
+ /** Genera la representación visual de un bloque para tablas y listas.
343
342
  * Distingue bloques grandes (`large::`) de bloques agrupados normales.
344
343
  */
345
344
  function blockLabel(id) {
@@ -352,6 +351,25 @@ function blockLabel(id) {
352
351
  }
353
352
  return `${badge("block", "blue")} ${chalk.whiteBright(id)}`;
354
353
  }
354
+ /**
355
+ * Genera una pastilla de terminal que indica el origen de un archivo en el árbol git.
356
+ *
357
+ * - REMOTO (cyan) : existe en commits ya publicados al remoto.
358
+ * - LOCAL (magenta) : commiteado localmente, aún no publicado.
359
+ * - WIP (yellow) : cambio en el working tree (staged o unstaged).
360
+ * - NUEVO (white) : archivo nuevo sin seguimiento git.
361
+ *
362
+ * @param origin - Valor de `FileOrigin` o `undefined` (trata como `"working-tree"`).
363
+ */
364
+ function originBadge(origin) {
365
+ switch (origin) {
366
+ case "pushed": return badge("REMOTO", "cyan");
367
+ case "local-commit": return badge("LOCAL", "magenta");
368
+ case "untracked": return badge("NUEVO", "white");
369
+ case "working-tree":
370
+ default: return badge("WIP", "yellow");
371
+ }
372
+ }
355
373
  /** Imprime una línea horizontal de 100 guiones en gris para separar entradas dentro de una sección. */
356
374
  function divider() {
357
375
  process.stdout.write(chalk.gray("─".repeat(100)) + "\n");
@@ -410,6 +428,7 @@ export const ui = {
410
428
  scoreColor,
411
429
  score,
412
430
  blockLabel,
431
+ originBadge,
413
432
  divider,
414
433
  confirm,
415
434
  prompt,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pull-request-split-advisor",
3
- "version": "3.2.0",
3
+ "version": "3.2.2",
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",
@@ -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) {