changebook 0.6.0 → 0.7.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/dist/sync.js CHANGED
@@ -12,6 +12,28 @@
12
12
  */
13
13
  import { readFile, writeFile } from 'node:fs/promises';
14
14
  import path from 'node:path';
15
+ import { aliasesFor, canonicalizeModuleRows, etiquetaPorSlug, projectIdOf, slugModule, } from './aliasDeModulo.js';
16
+ /**
17
+ * Cuántas citas de historial sirve el bloque, según el plan (B5, opción A).
18
+ *
19
+ * FALLA HACIA EL PLAN GRATUITO, SIEMPRE. Un error de red, una tabla que no
20
+ * responde o una fila que no existe devuelven el gratuito. Al revés —regalar el
21
+ * de pago cuando algo falla— es un muro que se cae solo el día que hay una
22
+ * incidencia, y nadie se entera de que se cayó.
23
+ *
24
+ * `user_plans` está acotada por RLS al usuario de la sesión, así que no hace
25
+ * falta filtrar por id: si hay fila, es la suya.
26
+ */
27
+ export async function citasSegunPlan(db) {
28
+ try {
29
+ const filas = await db.rest('user_plans?select=plan&limit=1');
30
+ const plan = (filas[0]?.plan ?? 'free').trim().toLowerCase();
31
+ return plan && plan !== 'free' ? CITAS_PRO : CITAS_GRATIS;
32
+ }
33
+ catch {
34
+ return CITAS_GRATIS;
35
+ }
36
+ }
15
37
  /** The project identity of the synced repo (same rule as analyze/guard). */
16
38
  function projectNameFor(targetDir) {
17
39
  return (process.env.CHANGEBOOK_PROJECT?.trim() ||
@@ -19,6 +41,11 @@ function projectNameFor(targetDir) {
19
41
  }
20
42
  const START = '<!-- changebook:start -->';
21
43
  const END = '<!-- changebook:end -->';
44
+ // La firma viaja con cada clone y cada PR: el bloque vive en el CLAUDE.md del
45
+ // usuario y todo colaborador (o su agente) que lo abra la ve. Una línea, sin
46
+ // emoji, sin mayúsculas, sin enlace markdown. Se RESERVA del presupuesto antes
47
+ // de repartirlo, así que si algo se recorta es el contenido, nunca la firma.
48
+ const FIRMA = '_Generado por ChangeBook · changebook.app_';
22
49
  // DB text (notes, alert bodies, business impact) is AI-written and gets embedded
23
50
  // between the markers that upsertSection splices on. If any of it contained a
24
51
  // literal marker, the next sync would splice at the wrong offset and corrupt the
@@ -27,6 +54,37 @@ const END = '<!-- changebook:end -->';
27
54
  function sanitizeCell(text) {
28
55
  return text.replace(/<!--/g, '<!- -');
29
56
  }
57
+ /**
58
+ * Recorta a `tope` caracteres sin partir palabras y DICIENDO que ha recortado.
59
+ *
60
+ * POR QUÉ EXISTE. Los topes de este bloque son deliberados —entra en cada
61
+ * sesión de agente y se reenvía en cada petición, así que es coste FIJO— pero
62
+ * se aplicaban con un `.slice(0, n)` pelado. El 04/08 el CLAUDE.md de este
63
+ * mismo repo decía «permitiendo validar q»: cortado a media palabra y sin
64
+ * ninguna marca. Eso no se lee como un extracto, se lee como salida rota, y
65
+ * este bloque es el artefacto más leído del producto.
66
+ *
67
+ * DOS COSAS, Y LAS DOS IMPORTAN:
68
+ *
69
+ * · La elipsis SOLO aparece si de verdad se recortó. Ponerla siempre haría
70
+ * que toda nota pareciera truncada, que es el error simétrico y igual de
71
+ * malo: dejaría de distinguir lo completo de lo cortado.
72
+ * · El hueco de la elipsis sale DE DENTRO del tope. El presupuesto está en
73
+ * caracteres; un tope que se desborda por la marca que anuncia el tope es
74
+ * un tope de mentira.
75
+ *
76
+ * Una palabra sola más larga que el tope se corta a lo bruto: retroceder al
77
+ * espacio anterior devolvería la cadena vacía, y perder la nota entera es peor
78
+ * que un corte feo. Ese caso está en el corpus del test.
79
+ */
80
+ export function recorta(texto, tope) {
81
+ if (texto.length <= tope)
82
+ return texto;
83
+ const cortado = texto.slice(0, tope - 1);
84
+ const ultimoEspacio = cortado.lastIndexOf(' ');
85
+ const base = ultimoEspacio > 0 ? cortado.slice(0, ultimoEspacio) : cortado;
86
+ return `${base.replace(/[\s.,;:]+$/, '')}…`;
87
+ }
30
88
  const MAX_MODULES = 15;
31
89
  const MAX_CHANGES = 5;
32
90
  const MAX_COUPLINGS = 5;
@@ -95,9 +153,9 @@ export async function fetchBriefSection(db, targetDir) {
95
153
  projectFilter = '&project_id=eq.00000000-0000-0000-0000-000000000000';
96
154
  projectResolved = false;
97
155
  }
98
- const projectId = /project_id=eq\.([0-9a-f-]+)/.exec(projectFilter)?.[1] ?? null;
156
+ const projectId = projectIdOf(projectFilter);
99
157
  const since = new Date(Date.now() - ALERT_WINDOW_DAYS * 24 * 3600 * 1000).toISOString();
100
- const [moduleRows, changes, alerts, historial, pendingTasks, healthRows] = await Promise.all([
158
+ const [moduleRowsCrudas, changes, alertsCrudas, historialCrudo, pendingTasks, healthRows, { aliases },] = await Promise.all([
101
159
  db.rest('change_module?select=changelog_id,module,domain,risk,files,note,created_at&order=created_at.desc&limit=500' +
102
160
  projectFilter),
103
161
  db.rest(`changelog?select=business_impact,created_at&order=created_at.desc&limit=${MAX_CHANGES}` +
@@ -109,7 +167,7 @@ export async function fetchBriefSection(db, targetDir) {
109
167
  // HISTORIA de regresiones: todas, sin ventana y sin filtrar por abiertas.
110
168
  // Es lo que sustituye al mapa de modulos en el bloque — ver `hechosCaros`.
111
169
  db
112
- .rest('regression_alerts?select=module,plain,created_at,changelog_id&order=created_at.desc&limit=500' +
170
+ .rest('regression_alerts?select=module,plain,resolution,created_at,changelog_id&order=created_at.desc&limit=500' +
113
171
  projectFilter)
114
172
  .catch(() => []),
115
173
  projectResolved && projectId
@@ -125,7 +183,19 @@ export async function fetchBriefSection(db, targetDir) {
125
183
  .rest('project_checks?select=check_id,status,evidence,updated_at&order=updated_at.desc&limit=8' +
126
184
  projectFilter)
127
185
  .catch(() => []),
186
+ // Los alias entran en el MISMO Promise.all que ya se hacía: no cuesta ronda.
187
+ aliasesFor(db, projectId),
128
188
  ]);
189
+ // El punto de estrangulamiento del bloque: se canonicaliza AQUÍ, nada más
190
+ // traer las filas, y todo lo que hay debajo (el mapa, `hechosCaros`,
191
+ // `coChangePairs`, los avisos) sigue siendo puro y agrupa por nombre sin
192
+ // enterarse. Las TRES tablas, no solo el mapa: el bloque de CLAUDE.md cuenta
193
+ // regresiones por módulo, así que una fusión que resolviera el mapa y no el
194
+ // historial diría «3 regresiones» de un módulo y las listaría bajo otro
195
+ // nombre, en la misma pantalla.
196
+ const moduleRows = canonicalizeModuleRows(moduleRowsCrudas, aliases);
197
+ const alerts = canonicalizeModuleRows(alertsCrudas, aliases);
198
+ const historial = canonicalizeModuleRows(historialCrudo, aliases);
129
199
  const health = summarizeHealth(healthRows);
130
200
  // El commit de cada regresion, para poder citarlo. Una consulta mas, y solo
131
201
  // por los analisis que de verdad rompieron algo: es la diferencia entre «este
@@ -147,9 +217,47 @@ export async function fetchBriefSection(db, targetDir) {
147
217
  commitPorAnalisis.set(f.id, f.commit_hash.slice(0, 7));
148
218
  }
149
219
  }
150
- const section = buildSection(moduleRows, changes, alerts, projectName, pendingTasks, health.at_risk, historial, commitPorAnalisis);
220
+ const section = buildSection(moduleRows, changes, alerts, projectName, pendingTasks, health.at_risk, historial, commitPorAnalisis, await citasSegunPlan(db));
151
221
  return { section, projectId, projectResolved };
152
222
  }
223
+ /**
224
+ * Lo que va a cambiar, en corto y para una persona.
225
+ *
226
+ * Pura a propósito: la decisión de qué enseñar se prueba sin terminal y sin
227
+ * escribir en disco. El diff entero se deja para quien lo pida — el objetivo de
228
+ * B7 no es volcar un diff, es que quien instala vea que su fichero se respeta.
229
+ */
230
+ export function resumenDelCambio(previo, siguiente, fichero) {
231
+ const nombre = fichero.split('/').pop() ?? fichero;
232
+ if (previo === null) {
233
+ return `Voy a crear ${nombre} (${siguiente.split('\n').length} líneas).`;
234
+ }
235
+ const antes = previo.split('\n');
236
+ const despues = siguiente.split('\n');
237
+ const comunes = new Set(antes);
238
+ const anadidas = despues.filter((l) => l.trim() && !comunes.has(l));
239
+ const vivas = new Set(despues);
240
+ const quitadas = antes.filter((l) => l.trim() && !vivas.has(l));
241
+ // LO QUE MÁS IMPORTA DE ESTE MENSAJE es la primera línea: que el fichero de
242
+ // quien lo lee NO se toca fuera del bloque. Es lo que el producto ya hacía
243
+ // bien y no contaba — y la desconfianza no viene de lo que haces, viene de lo
244
+ // que no se ve.
245
+ const fuera = antes.filter((l) => vivas.has(l)).length;
246
+ const l = [
247
+ `He encontrado tu ${nombre} (${antes.length} líneas). Conservo ${fuera} tal cual`,
248
+ `y solo reescribo el bloque entre los marcadores de ChangeBook:`,
249
+ '',
250
+ ];
251
+ for (const linea of anadidas.slice(0, 4))
252
+ l.push(` + ${linea.slice(0, 90)}`);
253
+ if (anadidas.length > 4)
254
+ l.push(` + … y ${anadidas.length - 4} líneas más`);
255
+ for (const linea of quitadas.slice(0, 2))
256
+ l.push(` - ${linea.slice(0, 90)}`);
257
+ if (quitadas.length > 2)
258
+ l.push(` - … y ${quitadas.length - 2} líneas más`);
259
+ return l.join('\n');
260
+ }
153
261
  export async function syncContextFiles(db, targetDir, opts = {}) {
154
262
  const { section, projectId, projectResolved } = await fetchBriefSection(db, targetDir);
155
263
  for (const name of ['CLAUDE.md', 'AGENTS.md']) {
@@ -199,9 +307,31 @@ export async function syncContextFiles(db, targetDir, opts = {}) {
199
307
  * `computeRecidivism`: la misma regresion re-levantada tres veces es UNA, y
200
308
  * contarla tres veces convertiria el churn del generador en falsa gravedad.
201
309
  */
202
- export function hechosCaros(historial, commitPorAnalisis, tope = 6) {
310
+ /**
311
+ * Citas por módulo en el plan gratuito. La línea es una pista para ir a mirar,
312
+ * no un informe.
313
+ */
314
+ export const CITAS_GRATIS = 3;
315
+ /**
316
+ * Y en el de pago (B5, opción A: nadie pierde nada, el que paga gana).
317
+ *
318
+ * ACOTADO, NO ILIMITADO, y el motivo está escrito dos veces en este fichero: el
319
+ * bloque viaja en CADA petición de CADA sesión del usuario, así que es coste
320
+ * FIJO, y `SYNC_BUDGET_CHARS` existe porque ya se intentó subir el techo en vez
321
+ * de adelgazar el contenido y NO funcionó — la sección «Módulos» se quedó fuera
322
+ * igual. «El que paga lo ve todo» sonaría mejor y le costaría tokens en cada
323
+ * turno para leer diez fechas que no va a mirar.
324
+ */
325
+ export const CITAS_PRO = 8;
326
+ export function hechosCaros(historial, commitPorAnalisis, tope = 6, maxCitas = CITAS_GRATIS) {
203
327
  const porModulo = new Map();
204
328
  for (const h of historial) {
329
+ // SOLO LAS CONFIRMADAS, igual que `computeRecidivism`. Esta es la sección
330
+ // que el dueño ve en su CLAUDE.md, así que dejarla contando avisos mientras
331
+ // la tool cuenta regresiones daría DOS cifras distintas para la misma
332
+ // pregunta — que es peor que una cifra mala.
333
+ if ((h.resolution ?? '').trim().toLowerCase() !== 'fixed')
334
+ continue;
205
335
  const modulo = (h.module ?? '').trim();
206
336
  const texto = (h.plain ?? '').trim();
207
337
  if (!modulo || !texto)
@@ -223,24 +353,37 @@ export function hechosCaros(historial, commitPorAnalisis, tope = 6) {
223
353
  .slice(0, tope)
224
354
  .map(([modulo, { textos, citas }]) => {
225
355
  const n = textos.size;
226
- // Tres citas como mucho: la linea es una pista para ir a mirar, no un
227
- // informe. Con mas, la seccion se come el presupuesto del bloque.
356
+ // Cuántas citas caben: ver CITAS_GRATIS / CITAS_PRO.
228
357
  const cuando = citas
229
- .slice(0, 3)
358
+ .slice(0, maxCitas)
230
359
  .map((c) => (c.commit ? `${c.fecha} \`${c.commit}\`` : c.fecha))
231
360
  .join(', ');
232
361
  return `- **${modulo}**: ${n} regresi${n === 1 ? 'ón' : 'ones'}${cuando ? ` (${cuando})` : ''}`;
233
362
  });
234
363
  }
235
- export function buildSection(rows, changes, alerts = [], projectName, pendingTasks = [], atRiskHealth = [], historial = [], commitPorAnalisis = new Map()) {
364
+ export function buildSection(rows, changes, alerts = [], projectName, pendingTasks = [], atRiskHealth = [], historial = [], commitPorAnalisis = new Map(),
365
+ // B5 · el que paga ve más historial de regresiones. Por defecto, el gratuito:
366
+ // un fallo al leer el plan tiene que degradar HACIA el plan libre, nunca
367
+ // regalar el de pago por un error de red.
368
+ maxCitas = CITAS_GRATIS) {
236
369
  // Newest-first rows: the first occurrence of a module is its latest state.
370
+ //
371
+ // POR SLUG (03/08). Agrupaba por `row.module` crudo, asi que un modulo
372
+ // escrito de dos maneras salia DOS VECES en el bloque que se escribe en
373
+ // CLAUDE.md/AGENTS.md — y ademas gastaba dos de las MAX_MODULES plazas, o
374
+ // sea que expulsaba a un modulo real. Este fichero ya agrupaba por slug en
375
+ // otros dos sitios; esta agregacion se habia quedado fuera, que es la forma
376
+ // exacta de la invariante 16: media normalizacion se lee igual que una
377
+ // entera.
378
+ const etiqueta = etiquetaPorSlug(rows.map((r) => r.module ?? ''));
237
379
  const seen = new Map();
238
380
  for (const row of rows) {
239
- const existing = seen.get(row.module);
381
+ const clave = slugModule((row.module ?? '').trim());
382
+ const existing = seen.get(clave);
240
383
  if (existing)
241
384
  existing.count += 1;
242
385
  else
243
- seen.set(row.module, { ...row, count: 1 });
386
+ seen.set(clave, { ...row, module: etiqueta.get(clave) ?? row.module, count: 1 });
244
387
  }
245
388
  const modules = [...seen.values()].slice(0, MAX_MODULES);
246
389
  const head = [
@@ -294,7 +437,16 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
294
437
  // tokens y CERO llamadas al atlas. «editar un archivo» no casaba con cómo
295
438
  // habla quien programa con un agente. Quien no pregunta nada no reconoce un
296
439
  // disparador abstracto: hay que nombrar sus verbos.
297
- 'Disparador → tool: orientarte → `atlas_project_brief` (`intent:"orient"`) · antes de editar/cambiar/arreglar/refactorizar un archivo o función → `atlas_file_context` (dice QUIÉN DEPENDE de eso) · riesgos/acoplamientos → el brief. Tras cada commit: `atlas_record_change` (diff, hash, fecha, `summary` tuyo; reenviar es gratis).',
440
+ //
441
+ // Y el 2026-08-02 se vio la MITAD QUE FALTABA. Habia disparador para
442
+ // registrar cambios y ninguno para juzgar avisos, y el resultado esta
443
+ // medido: de 119 alertas, 79 sin motivo ninguno. No es que nadie las
444
+ // mirara — mirar y APUNTAR son dos actos distintos y solo uno era gratis.
445
+ // Ese dia yo mismo verifique un aviso, lo arregle y no lo cerre hasta que
446
+ // el dueño me pregunto por que seguia abierto. `atlas_resolve_alert` entra
447
+ // con sus verbos («comprobar», «arreglar») por la misma ley de arriba, y
448
+ // pagando su precio: cada char aqui le quita sitio al mapa.
449
+ 'Disparador → tool: orientarte → `atlas_project_brief` (`intent:"orient"`) · antes de editar/cambiar/arreglar/refactorizar un archivo o función → `atlas_file_context` (dice QUIÉN DEPENDE de eso) · riesgos/acoplamientos → el brief. Tras cada commit: `atlas_record_change` (diff, hash, fecha, `summary` tuyo; reenviar es gratis). Al comprobar o arreglar un aviso: `atlas_resolve_alert` (si era falso, `verdict:"dismissed"`).',
298
450
  '',
299
451
  'Tools diferidas, en UNA llamada: ToolSearch `select:mcp__changebook__atlas_project_brief,mcp__changebook__atlas_file_context`.',
300
452
  '',
@@ -343,21 +495,21 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
343
495
  // un parrafo. Tres alertas a 200 chars se comian el 30% del presupuesto
344
496
  // y expulsaban el mapa. El texto entero sigue a una llamada de
345
497
  // `atlas_project_brief`, donde se paga solo si alguien pregunta.
346
- return `- ${a.created_at.slice(0, 10)}${mod ? ` · **${mod}**` : ''} — ${sanitizeCell((a.plain ?? '').slice(0, 130))}`;
498
+ return `- ${a.created_at.slice(0, 10)}${mod ? ` · **${mod}**` : ''} — ${sanitizeCell(recorta(a.plain ?? '', 130))}`;
347
499
  });
348
500
  const hotspotLines = modules
349
501
  .filter((m) => m.risk === 'hotspot')
350
502
  .slice(0, 5)
351
503
  .map((m) => {
352
- const note = sanitizeCell((m.note ?? '').slice(0, 110));
504
+ const note = sanitizeCell(recorta(m.note ?? '', 110));
353
505
  return `- **${m.module}** está marcado crítico${note ? ` — ${note}` : ''}`;
354
506
  });
355
- const changeLines = changes.map((c) => `- ${c.created_at.slice(0, 10)} — ${sanitizeCell((c.business_impact ?? '').slice(0, 140))}`);
507
+ const changeLines = changes.map((c) => `- ${c.created_at.slice(0, 10)} — ${sanitizeCell(recorta(c.business_impact ?? '', 140))}`);
356
508
  // Salud en riesgo: los controles (auth, secretos, validación, límites…) que
357
509
  // el análisis ya marcó rotos con evidencia. Alta prioridad de presupuesto —
358
510
  // es seguridad. Solo los at_risk (lo accionable); el texto entero va a
359
511
  // `atlas_project_brief`. Cap a 130 chars como las alertas.
360
- const healthLines = atRiskHealth.map((h) => `- ⚠ **${h.check}**${h.since ? ` (desde ${h.since})` : ''} — ${sanitizeCell(h.evidence.slice(0, 130))}`);
512
+ const healthLines = atRiskHealth.map((h) => `- ⚠ **${h.check}**${h.since ? ` (desde ${h.since})` : ''} — ${sanitizeCell(recorta(h.evidence, 130))}`);
361
513
  // Auto-remediación fase 2: la cola entra en cada sesión para que el agente
362
514
  // se OFREZCA a atacarla — proponer con plan y esperar el OK del humano,
363
515
  // nunca ejecutar por su cuenta. Títulos deduplicados (la cola real puede
@@ -368,7 +520,7 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
368
520
  const taskLines = taskTitles.length > 0
369
521
  ? [
370
522
  `- Hay ${pendingTasks.length} encargo(s) pendientes en la cola de este proyecto. Propón cuál atacarías. Cola viva: \`atlas_pending_tasks\`; cierra con \`atlas_complete_task\`.`,
371
- ...taskTitles.map((t) => `- ${sanitizeCell(t.slice(0, 120))}`),
523
+ ...taskTitles.map((t) => `- ${sanitizeCell(recorta(t, 120))}`),
372
524
  ]
373
525
  : [];
374
526
  // El orden de renderizado (legibilidad) y la prioridad de presupuesto (qué se
@@ -424,7 +576,7 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
424
576
  key: 'costoso',
425
577
  priority: 1,
426
578
  title: '### Lo que ya costó caro aquí (revisa antes de tocarlo)',
427
- lines: hechosCaros(historial, commitPorAnalisis),
579
+ lines: hechosCaros(historial, commitPorAnalisis, 6, maxCitas),
428
580
  },
429
581
  // El mapa baja de 3 a 4, y no es una degradación caprichosa: `/doctor` de
430
582
  // Claude Code recorta por su cuenta «architecture overviews» de un
@@ -439,7 +591,10 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
439
591
  lines: changeLines,
440
592
  },
441
593
  ];
442
- let budget = SYNC_BUDGET_CHARS - head.join('\n').length - END.length;
594
+ // La firma se aparta del fondo ANTES del reparto (línea en blanco separadora
595
+ // + la propia línea): lo que compita por presupuesto lo hace sobre lo que
596
+ // sobra, de modo que el recorte cae siempre en el contenido, nunca en la firma.
597
+ let budget = SYNC_BUDGET_CHARS - head.join('\n').length - END.length - (FIRMA.length + 2);
443
598
  // ── El suelo del mapa ──────────────────────────────────────────────────────
444
599
  //
445
600
  // El presupuesto es de suma cero, así que poner el riesgo primero se lo come.
@@ -524,6 +679,9 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
524
679
  lines.push(s.title, ...s.lines.slice(0, count));
525
680
  first = false;
526
681
  }
682
+ // La firma, al final del bloque y antes del marcador de cierre. Su hueco ya
683
+ // se apartó del presupuesto arriba, así que entra siempre.
684
+ lines.push('', FIRMA);
527
685
  lines.push(END);
528
686
  return lines.join('\n');
529
687
  }
@@ -571,11 +729,17 @@ const ES_FICHERO_DE_PRUEBAS = /(^|\/)(tests?|__tests__|spec)\/|\.(test|spec)\.[A
571
729
  */
572
730
  const RATIO_MODULO_DE_PRUEBAS = 0.8;
573
731
  export function modulosDePruebas(rows) {
732
+ // Por slug: si el módulo va partido en dos etiquetas, cada mitad se evaluaría
733
+ // contra el umbral por su cuenta y una podría pasar por «de pruebas» mientras
734
+ // la otra no. El Set devuelto lleva la etiqueta representante, que es la
735
+ // misma que usa coChangePairs, para que el `dePruebas.has(...)` de allí case.
736
+ const etiqueta = etiquetaPorSlug(rows.map((r) => r.module ?? ''));
574
737
  const porModulo = new Map();
575
738
  for (const r of rows) {
576
- const label = (r.module ?? '').trim();
577
- if (!label || !Array.isArray(r.files))
739
+ const crudo = (r.module ?? '').trim();
740
+ if (!crudo || !Array.isArray(r.files))
578
741
  continue;
742
+ const label = etiqueta.get(slugModule(crudo)) ?? crudo;
579
743
  const set = porModulo.get(label) ?? new Set();
580
744
  for (const f of r.files) {
581
745
  if (typeof f === 'string' && f.trim())
@@ -597,11 +761,19 @@ export function modulosDePruebas(rows) {
597
761
  return fuera;
598
762
  }
599
763
  export function coChangePairs(rows) {
764
+ // LA ARISTA QUE FALTA NO LA VE NADIE. Un módulo partido en dos etiquetas
765
+ // reparte sus apariciones entre las dos mitades: con 13+1 se pierde poco,
766
+ // pero con un reparto parejo —8 y 6 contra un umbral de 10— las DOS mitades
767
+ // caen por debajo de MIN_PAIR_COUNT y el acoplamiento real desaparece del
768
+ // grafo. El fantasma del brief se ve y se denuncia; esto no lo nota nadie
769
+ // nunca, y es justo lo que el producto promete enseñar.
770
+ const etiqueta = etiquetaPorSlug(rows.map((r) => r.module ?? ''));
600
771
  const byAnalysis = new Map();
601
772
  for (const r of rows) {
602
- const label = (r.module ?? '').trim();
603
- if (!label)
773
+ const crudo = (r.module ?? '').trim();
774
+ if (!crudo)
604
775
  continue;
776
+ const label = etiqueta.get(slugModule(crudo)) ?? crudo;
605
777
  let set = byAnalysis.get(r.changelog_id);
606
778
  if (!set) {
607
779
  set = new Set();
@@ -647,11 +819,25 @@ export async function upsertSection(file, section, opts = {}) {
647
819
  catch {
648
820
  content = null;
649
821
  }
822
+ /** Escribe, salvo que quien mira diga que no. */
823
+ const escribir = async (siguiente, resultado) => {
824
+ if (opts.confirmar) {
825
+ const ok = await opts.confirmar({
826
+ fichero: file,
827
+ previo: content,
828
+ siguiente,
829
+ resumen: resumenDelCambio(content, siguiente, file),
830
+ });
831
+ if (!ok)
832
+ return 'skipped';
833
+ }
834
+ await writeFile(file, siguiente);
835
+ return resultado;
836
+ };
650
837
  if (content === null) {
651
838
  if (opts.refreshOnly)
652
839
  return 'skipped';
653
- await writeFile(file, section + '\n');
654
- return 'created';
840
+ return escribir(section + '\n', 'created');
655
841
  }
656
842
  // Migración del renombrado (AppAtlas → ChangeBook): si el archivo aún tiene
657
843
  // el bloque con los marcadores antiguos, elimínalo antes de upsertar el
@@ -679,13 +865,13 @@ export async function upsertSection(file, section, opts = {}) {
679
865
  const next = content.slice(0, start) + section + content.slice(end + END.length);
680
866
  // Idempotente: si el mapa no cambió, no tocar el archivo — reescribirlo
681
867
  // actualizaría el mtime, ensuciaría git status y podría invalidar cachés.
868
+ // Y NO SE PREGUNTA cuando no hay nada que cambiar: una pregunta cuya única
869
+ // respuesta útil es «sí, no hagas nada» es ruido que enseña a decir que sí.
682
870
  if (next === content)
683
871
  return 'unchanged';
684
- await writeFile(file, next);
685
- return 'updated';
872
+ return escribir(next, 'updated');
686
873
  }
687
874
  const separator = content.endsWith('\n') ? '\n' : '\n\n';
688
- await writeFile(file, content + separator + section + '\n');
689
- return 'appended';
875
+ return escribir(content + separator + section + '\n', 'appended');
690
876
  }
691
877
  //# sourceMappingURL=sync.js.map