changebook 0.5.0 → 0.7.0

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
@@ -95,9 +122,9 @@ export async function fetchBriefSection(db, targetDir) {
95
122
  projectFilter = '&project_id=eq.00000000-0000-0000-0000-000000000000';
96
123
  projectResolved = false;
97
124
  }
98
- const projectId = /project_id=eq\.([0-9a-f-]+)/.exec(projectFilter)?.[1] ?? null;
125
+ const projectId = projectIdOf(projectFilter);
99
126
  const since = new Date(Date.now() - ALERT_WINDOW_DAYS * 24 * 3600 * 1000).toISOString();
100
- const [moduleRows, changes, alerts, pendingTasks, healthRows] = await Promise.all([
127
+ const [moduleRowsCrudas, changes, alertsCrudas, historialCrudo, pendingTasks, healthRows, { aliases },] = await Promise.all([
101
128
  db.rest('change_module?select=changelog_id,module,domain,risk,files,note,created_at&order=created_at.desc&limit=500' +
102
129
  projectFilter),
103
130
  db.rest(`changelog?select=business_impact,created_at&order=created_at.desc&limit=${MAX_CHANGES}` +
@@ -106,6 +133,12 @@ export async function fetchBriefSection(db, targetDir) {
106
133
  .rest(`regression_alerts?select=module,plain,created_at&created_at=gte.${since}&resolved_at=is.null&order=created_at.desc&limit=${MAX_ALERTS}` +
107
134
  projectFilter)
108
135
  .catch(() => []),
136
+ // HISTORIA de regresiones: todas, sin ventana y sin filtrar por abiertas.
137
+ // Es lo que sustituye al mapa de modulos en el bloque — ver `hechosCaros`.
138
+ db
139
+ .rest('regression_alerts?select=module,plain,resolution,created_at,changelog_id&order=created_at.desc&limit=500' +
140
+ projectFilter)
141
+ .catch(() => []),
109
142
  projectResolved && projectId
110
143
  ? db
111
144
  .callRpc('list_agent_tasks', {
@@ -119,11 +152,81 @@ export async function fetchBriefSection(db, targetDir) {
119
152
  .rest('project_checks?select=check_id,status,evidence,updated_at&order=updated_at.desc&limit=8' +
120
153
  projectFilter)
121
154
  .catch(() => []),
155
+ // Los alias entran en el MISMO Promise.all que ya se hacía: no cuesta ronda.
156
+ aliasesFor(db, projectId),
122
157
  ]);
158
+ // El punto de estrangulamiento del bloque: se canonicaliza AQUÍ, nada más
159
+ // traer las filas, y todo lo que hay debajo (el mapa, `hechosCaros`,
160
+ // `coChangePairs`, los avisos) sigue siendo puro y agrupa por nombre sin
161
+ // enterarse. Las TRES tablas, no solo el mapa: el bloque de CLAUDE.md cuenta
162
+ // regresiones por módulo, así que una fusión que resolviera el mapa y no el
163
+ // historial diría «3 regresiones» de un módulo y las listaría bajo otro
164
+ // nombre, en la misma pantalla.
165
+ const moduleRows = canonicalizeModuleRows(moduleRowsCrudas, aliases);
166
+ const alerts = canonicalizeModuleRows(alertsCrudas, aliases);
167
+ const historial = canonicalizeModuleRows(historialCrudo, aliases);
123
168
  const health = summarizeHealth(healthRows);
124
- const section = buildSection(moduleRows, changes, alerts, projectName, pendingTasks, health.at_risk);
169
+ // El commit de cada regresion, para poder citarlo. Una consulta mas, y solo
170
+ // por los analisis que de verdad rompieron algo: es la diferencia entre «este
171
+ // modulo ha roto 3 veces» y «este modulo ha roto 3 veces, aqui, aqui y aqui».
172
+ // Sin el hash, la frase es una etiqueta; con el, es algo que se puede ir a
173
+ // mirar. El sync corre UNA vez por sesion, asi que la ronda extra se paga una
174
+ // vez y no por edicion.
175
+ const idsDeRegresion = [
176
+ ...new Set(historial.map((h) => h.changelog_id).filter((x) => Boolean(x))),
177
+ ].slice(0, 60);
178
+ const commitPorAnalisis = new Map();
179
+ if (idsDeRegresion.length > 0) {
180
+ const filas = await db
181
+ .rest(`changelog?select=id,commit_hash&id=in.(${idsDeRegresion.join(',')})` +
182
+ projectFilter)
183
+ .catch(() => []);
184
+ for (const f of filas) {
185
+ if (f.commit_hash)
186
+ commitPorAnalisis.set(f.id, f.commit_hash.slice(0, 7));
187
+ }
188
+ }
189
+ const section = buildSection(moduleRows, changes, alerts, projectName, pendingTasks, health.at_risk, historial, commitPorAnalisis, await citasSegunPlan(db));
125
190
  return { section, projectId, projectResolved };
126
191
  }
192
+ /**
193
+ * Lo que va a cambiar, en corto y para una persona.
194
+ *
195
+ * Pura a propósito: la decisión de qué enseñar se prueba sin terminal y sin
196
+ * escribir en disco. El diff entero se deja para quien lo pida — el objetivo de
197
+ * B7 no es volcar un diff, es que quien instala vea que su fichero se respeta.
198
+ */
199
+ export function resumenDelCambio(previo, siguiente, fichero) {
200
+ const nombre = fichero.split('/').pop() ?? fichero;
201
+ if (previo === null) {
202
+ return `Voy a crear ${nombre} (${siguiente.split('\n').length} líneas).`;
203
+ }
204
+ const antes = previo.split('\n');
205
+ const despues = siguiente.split('\n');
206
+ const comunes = new Set(antes);
207
+ const anadidas = despues.filter((l) => l.trim() && !comunes.has(l));
208
+ const vivas = new Set(despues);
209
+ const quitadas = antes.filter((l) => l.trim() && !vivas.has(l));
210
+ // LO QUE MÁS IMPORTA DE ESTE MENSAJE es la primera línea: que el fichero de
211
+ // quien lo lee NO se toca fuera del bloque. Es lo que el producto ya hacía
212
+ // bien y no contaba — y la desconfianza no viene de lo que haces, viene de lo
213
+ // que no se ve.
214
+ const fuera = antes.filter((l) => vivas.has(l)).length;
215
+ const l = [
216
+ `He encontrado tu ${nombre} (${antes.length} líneas). Conservo ${fuera} tal cual`,
217
+ `y solo reescribo el bloque entre los marcadores de ChangeBook:`,
218
+ '',
219
+ ];
220
+ for (const linea of anadidas.slice(0, 4))
221
+ l.push(` + ${linea.slice(0, 90)}`);
222
+ if (anadidas.length > 4)
223
+ l.push(` + … y ${anadidas.length - 4} líneas más`);
224
+ for (const linea of quitadas.slice(0, 2))
225
+ l.push(` - ${linea.slice(0, 90)}`);
226
+ if (quitadas.length > 2)
227
+ l.push(` - … y ${quitadas.length - 2} líneas más`);
228
+ return l.join('\n');
229
+ }
127
230
  export async function syncContextFiles(db, targetDir, opts = {}) {
128
231
  const { section, projectId, projectResolved } = await fetchBriefSection(db, targetDir);
129
232
  for (const name of ['CLAUDE.md', 'AGENTS.md']) {
@@ -146,15 +249,110 @@ export async function syncContextFiles(db, targetDir, opts = {}) {
146
249
  }
147
250
  }
148
251
  /** Exported for tests. */
149
- export function buildSection(rows, changes, alerts = [], projectName, pendingTasks = [], atRiskHealth = []) {
252
+ /**
253
+ * LO QUE YA COSTO CARO AQUI: modulos que han roto algo, con fecha y commit.
254
+ *
255
+ * ── POR QUE ESTO Y NO EL MAPA DE MODULOS ────────────────────────────────────
256
+ *
257
+ * La documentacion de Claude Code lo dice de su propio `/doctor`: recorta del
258
+ * CLAUDE.md «directory layouts, dependency lists, and architecture overviews»
259
+ * y conserva «pitfalls, rationale, and conventions that differ from tool
260
+ * defaults». La lista de modulos con su dominio es DERIVABLE: el agente la saca
261
+ * leyendo el repo, y la herramienta la recorta por su cuenta.
262
+ *
263
+ * Esto no. Que un modulo haya roto tres veces sale del HISTORIAL, no del
264
+ * codigo: ni un grep ni un glob pueden encontrarlo, y es exactamente la
265
+ * categoria que /doctor conserva.
266
+ *
267
+ * ── DOS DECISIONES QUE PARECEN DETALLES ─────────────────────────────────────
268
+ *
269
+ * FECHA Y HASH POR ENTRADA, NUNCA EN LA CABECERA. Una fecha en la cabecera
270
+ * cambiaria a diario e invalidaria el prefijo de prompt-cache de TODO el
271
+ * fichero en cada sesion. Por entrada no: una linea que no cambia no invalida
272
+ * nada, y `upsertSection` sigue devolviendo `unchanged` cuando no ha pasado
273
+ * nada, asi que hechos con fecha no producen churn diario en `git status`.
274
+ *
275
+ * SE CUENTAN PROBLEMAS DISTINTOS, no filas. Mismo criterio que
276
+ * `computeRecidivism`: la misma regresion re-levantada tres veces es UNA, y
277
+ * contarla tres veces convertiria el churn del generador en falsa gravedad.
278
+ */
279
+ /**
280
+ * Citas por módulo en el plan gratuito. La línea es una pista para ir a mirar,
281
+ * no un informe.
282
+ */
283
+ export const CITAS_GRATIS = 3;
284
+ /**
285
+ * Y en el de pago (B5, opción A: nadie pierde nada, el que paga gana).
286
+ *
287
+ * ACOTADO, NO ILIMITADO, y el motivo está escrito dos veces en este fichero: el
288
+ * bloque viaja en CADA petición de CADA sesión del usuario, así que es coste
289
+ * FIJO, y `SYNC_BUDGET_CHARS` existe porque ya se intentó subir el techo en vez
290
+ * de adelgazar el contenido y NO funcionó — la sección «Módulos» se quedó fuera
291
+ * igual. «El que paga lo ve todo» sonaría mejor y le costaría tokens en cada
292
+ * turno para leer diez fechas que no va a mirar.
293
+ */
294
+ export const CITAS_PRO = 8;
295
+ export function hechosCaros(historial, commitPorAnalisis, tope = 6, maxCitas = CITAS_GRATIS) {
296
+ const porModulo = new Map();
297
+ for (const h of historial) {
298
+ // SOLO LAS CONFIRMADAS, igual que `computeRecidivism`. Esta es la sección
299
+ // que el dueño ve en su CLAUDE.md, así que dejarla contando avisos mientras
300
+ // la tool cuenta regresiones daría DOS cifras distintas para la misma
301
+ // pregunta — que es peor que una cifra mala.
302
+ if ((h.resolution ?? '').trim().toLowerCase() !== 'fixed')
303
+ continue;
304
+ const modulo = (h.module ?? '').trim();
305
+ const texto = (h.plain ?? '').trim();
306
+ if (!modulo || !texto)
307
+ continue;
308
+ const entrada = porModulo.get(modulo) ?? { textos: new Set(), citas: [] };
309
+ if (!entrada.textos.has(texto)) {
310
+ entrada.textos.add(texto);
311
+ entrada.citas.push({
312
+ fecha: h.created_at.slice(5, 10).split('-').reverse().join('/'),
313
+ commit: h.changelog_id
314
+ ? (commitPorAnalisis.get(h.changelog_id) ?? null)
315
+ : null,
316
+ });
317
+ }
318
+ porModulo.set(modulo, entrada);
319
+ }
320
+ return [...porModulo.entries()]
321
+ .sort((a, b) => b[1].textos.size - a[1].textos.size || a[0].localeCompare(b[0]))
322
+ .slice(0, tope)
323
+ .map(([modulo, { textos, citas }]) => {
324
+ const n = textos.size;
325
+ // Cuántas citas caben: ver CITAS_GRATIS / CITAS_PRO.
326
+ const cuando = citas
327
+ .slice(0, maxCitas)
328
+ .map((c) => (c.commit ? `${c.fecha} \`${c.commit}\`` : c.fecha))
329
+ .join(', ');
330
+ return `- **${modulo}**: ${n} regresi${n === 1 ? 'ón' : 'ones'}${cuando ? ` (${cuando})` : ''}`;
331
+ });
332
+ }
333
+ export function buildSection(rows, changes, alerts = [], projectName, pendingTasks = [], atRiskHealth = [], historial = [], commitPorAnalisis = new Map(),
334
+ // B5 · el que paga ve más historial de regresiones. Por defecto, el gratuito:
335
+ // un fallo al leer el plan tiene que degradar HACIA el plan libre, nunca
336
+ // regalar el de pago por un error de red.
337
+ maxCitas = CITAS_GRATIS) {
150
338
  // Newest-first rows: the first occurrence of a module is its latest state.
339
+ //
340
+ // POR SLUG (03/08). Agrupaba por `row.module` crudo, asi que un modulo
341
+ // escrito de dos maneras salia DOS VECES en el bloque que se escribe en
342
+ // CLAUDE.md/AGENTS.md — y ademas gastaba dos de las MAX_MODULES plazas, o
343
+ // sea que expulsaba a un modulo real. Este fichero ya agrupaba por slug en
344
+ // otros dos sitios; esta agregacion se habia quedado fuera, que es la forma
345
+ // exacta de la invariante 16: media normalizacion se lee igual que una
346
+ // entera.
347
+ const etiqueta = etiquetaPorSlug(rows.map((r) => r.module ?? ''));
151
348
  const seen = new Map();
152
349
  for (const row of rows) {
153
- const existing = seen.get(row.module);
350
+ const clave = slugModule((row.module ?? '').trim());
351
+ const existing = seen.get(clave);
154
352
  if (existing)
155
353
  existing.count += 1;
156
354
  else
157
- seen.set(row.module, { ...row, count: 1 });
355
+ seen.set(clave, { ...row, module: etiqueta.get(clave) ?? row.module, count: 1 });
158
356
  }
159
357
  const modules = [...seen.values()].slice(0, MAX_MODULES);
160
358
  const head = [
@@ -208,9 +406,18 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
208
406
  // tokens y CERO llamadas al atlas. «editar un archivo» no casaba con cómo
209
407
  // habla quien programa con un agente. Quien no pregunta nada no reconoce un
210
408
  // disparador abstracto: hay que nombrar sus verbos.
211
- '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).',
409
+ //
410
+ // Y el 2026-08-02 se vio la MITAD QUE FALTABA. Habia disparador para
411
+ // registrar cambios y ninguno para juzgar avisos, y el resultado esta
412
+ // medido: de 119 alertas, 79 sin motivo ninguno. No es que nadie las
413
+ // mirara — mirar y APUNTAR son dos actos distintos y solo uno era gratis.
414
+ // Ese dia yo mismo verifique un aviso, lo arregle y no lo cerre hasta que
415
+ // el dueño me pregunto por que seguia abierto. `atlas_resolve_alert` entra
416
+ // con sus verbos («comprobar», «arreglar») por la misma ley de arriba, y
417
+ // pagando su precio: cada char aqui le quita sitio al mapa.
418
+ '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"`).',
212
419
  '',
213
- 'Tools diferidas, en UNA llamada: ToolSearch `select:mcp__changebook__atlas_project_brief,mcp__changebook__atlas_file_context,mcp__changebook__atlas_symbol_lookup`.',
420
+ 'Tools diferidas, en UNA llamada: ToolSearch `select:mcp__changebook__atlas_project_brief,mcp__changebook__atlas_file_context`.',
214
421
  '',
215
422
  // Proyecto + conducta en UNA línea: cada char de cabecera expulsa mapa.
216
423
  //
@@ -334,7 +541,18 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
334
541
  title: '### Encargos pendientes del dueño (proponte atacarlos)',
335
542
  lines: taskLines,
336
543
  },
337
- { key: 'modules', priority: 3, title: '### Módulos', lines: moduleLines },
544
+ {
545
+ key: 'costoso',
546
+ priority: 1,
547
+ title: '### Lo que ya costó caro aquí (revisa antes de tocarlo)',
548
+ lines: hechosCaros(historial, commitPorAnalisis, 6, maxCitas),
549
+ },
550
+ // El mapa baja de 3 a 4, y no es una degradación caprichosa: `/doctor` de
551
+ // Claude Code recorta por su cuenta «architecture overviews» de un
552
+ // CLAUDE.md porque el agente los deriva del repo. Lo que no puede derivar
553
+ // es qué ha roto antes. Se conserva la sección —quitarla del todo cambia
554
+ // más de lo necesario— pero deja de competir con los hechos.
555
+ { key: 'modules', priority: 4, title: '### Módulos', lines: moduleLines },
338
556
  {
339
557
  key: 'changes',
340
558
  priority: 5,
@@ -342,7 +560,10 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
342
560
  lines: changeLines,
343
561
  },
344
562
  ];
345
- let budget = SYNC_BUDGET_CHARS - head.join('\n').length - END.length;
563
+ // La firma se aparta del fondo ANTES del reparto (línea en blanco separadora
564
+ // + la propia línea): lo que compita por presupuesto lo hace sobre lo que
565
+ // sobra, de modo que el recorte cae siempre en el contenido, nunca en la firma.
566
+ let budget = SYNC_BUDGET_CHARS - head.join('\n').length - END.length - (FIRMA.length + 2);
346
567
  // ── El suelo del mapa ──────────────────────────────────────────────────────
347
568
  //
348
569
  // El presupuesto es de suma cero, así que poner el riesgo primero se lo come.
@@ -359,8 +580,17 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
359
580
  // reserva lo que cuestan las primeras MIN_LINEAS_MAPA líneas del mapa, y solo
360
581
  // el resto se disputa por prioridad. Si el mapa tiene menos líneas que el
361
582
  // suelo, sobra menos reserva; si no hay mapa, no se reserva nada.
362
- const MIN_LINEAS_MAPA = 6;
363
- const mapa = sections.find((x) => x.key === 'modules');
583
+ // EL SUELO CAMBIA DE DUEÑO. Lo tenía el mapa porque el estudio de 76 agentes
584
+ // del 2026-07-20 concluyó que un mapa expulsado era «la causa 1 de que el
585
+ // agente ignore o desconfíe del atlas». Sigue siendo cierto, pero el mapa ya
586
+ // no es lo irreemplazable: el agente lo deriva leyendo el repo, y la propia
587
+ // herramienta lo recorta. Lo que nadie puede derivar es qué rompió antes, así
588
+ // que el suelo protege ahora eso.
589
+ //
590
+ // Y baja de 6 líneas a 3: son entradas mucho más densas —un módulo, un
591
+ // número y hasta tres commits— así que tres ya dicen dónde pisar con cuidado.
592
+ const MIN_LINEAS_MAPA = 3;
593
+ const mapa = sections.find((x) => x.key === 'costoso');
364
594
  let reservaMapa = 0;
365
595
  if (mapa && mapa.lines.length > 0) {
366
596
  reservaMapa = mapa.title.length + 2;
@@ -380,7 +610,7 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
380
610
  if (s.lines.length === 0)
381
611
  continue;
382
612
  // El mapa gasta su reserva ADEMÁS de lo que haya quedado libre.
383
- const disponible = s.key === 'modules' ? budget + reservaMapa : budget;
613
+ const disponible = s.key === 'costoso' ? budget + reservaMapa : budget;
384
614
  let cost = s.title.length + 2; // título + línea en blanco separadora
385
615
  let count = 0;
386
616
  for (const line of s.lines) {
@@ -391,8 +621,14 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
391
621
  }
392
622
  if (count > 0) {
393
623
  includedCount.set(s.key, count);
394
- if (s.key === 'modules') {
624
+ if (s.key === 'costoso') {
395
625
  // Lo que consumió por encima de su reserva sale del fondo común.
626
+ //
627
+ // La clave tiene que ser la MISMA que la de arriba: al mover el suelo
628
+ // del mapa a los hechos se me quedó aquí `modules`, así que la sección
629
+ // nueva gastaba la reserva Y descontaba su coste entero del fondo —
630
+ // doble gasto— y el mapa se llevaba luego una reserva ya usada. El
631
+ // bloque no reventaba el techo por casualidad, no por diseño.
396
632
  budget -= Math.max(0, cost - reservaMapa);
397
633
  reservaMapa = 0;
398
634
  }
@@ -412,6 +648,9 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
412
648
  lines.push(s.title, ...s.lines.slice(0, count));
413
649
  first = false;
414
650
  }
651
+ // La firma, al final del bloque y antes del marcador de cierre. Su hueco ya
652
+ // se apartó del presupuesto arriba, así que entra siempre.
653
+ lines.push('', FIRMA);
415
654
  lines.push(END);
416
655
  return lines.join('\n');
417
656
  }
@@ -459,11 +698,17 @@ const ES_FICHERO_DE_PRUEBAS = /(^|\/)(tests?|__tests__|spec)\/|\.(test|spec)\.[A
459
698
  */
460
699
  const RATIO_MODULO_DE_PRUEBAS = 0.8;
461
700
  export function modulosDePruebas(rows) {
701
+ // Por slug: si el módulo va partido en dos etiquetas, cada mitad se evaluaría
702
+ // contra el umbral por su cuenta y una podría pasar por «de pruebas» mientras
703
+ // la otra no. El Set devuelto lleva la etiqueta representante, que es la
704
+ // misma que usa coChangePairs, para que el `dePruebas.has(...)` de allí case.
705
+ const etiqueta = etiquetaPorSlug(rows.map((r) => r.module ?? ''));
462
706
  const porModulo = new Map();
463
707
  for (const r of rows) {
464
- const label = (r.module ?? '').trim();
465
- if (!label || !Array.isArray(r.files))
708
+ const crudo = (r.module ?? '').trim();
709
+ if (!crudo || !Array.isArray(r.files))
466
710
  continue;
711
+ const label = etiqueta.get(slugModule(crudo)) ?? crudo;
467
712
  const set = porModulo.get(label) ?? new Set();
468
713
  for (const f of r.files) {
469
714
  if (typeof f === 'string' && f.trim())
@@ -485,11 +730,19 @@ export function modulosDePruebas(rows) {
485
730
  return fuera;
486
731
  }
487
732
  export function coChangePairs(rows) {
733
+ // LA ARISTA QUE FALTA NO LA VE NADIE. Un módulo partido en dos etiquetas
734
+ // reparte sus apariciones entre las dos mitades: con 13+1 se pierde poco,
735
+ // pero con un reparto parejo —8 y 6 contra un umbral de 10— las DOS mitades
736
+ // caen por debajo de MIN_PAIR_COUNT y el acoplamiento real desaparece del
737
+ // grafo. El fantasma del brief se ve y se denuncia; esto no lo nota nadie
738
+ // nunca, y es justo lo que el producto promete enseñar.
739
+ const etiqueta = etiquetaPorSlug(rows.map((r) => r.module ?? ''));
488
740
  const byAnalysis = new Map();
489
741
  for (const r of rows) {
490
- const label = (r.module ?? '').trim();
491
- if (!label)
742
+ const crudo = (r.module ?? '').trim();
743
+ if (!crudo)
492
744
  continue;
745
+ const label = etiqueta.get(slugModule(crudo)) ?? crudo;
493
746
  let set = byAnalysis.get(r.changelog_id);
494
747
  if (!set) {
495
748
  set = new Set();
@@ -535,11 +788,25 @@ export async function upsertSection(file, section, opts = {}) {
535
788
  catch {
536
789
  content = null;
537
790
  }
791
+ /** Escribe, salvo que quien mira diga que no. */
792
+ const escribir = async (siguiente, resultado) => {
793
+ if (opts.confirmar) {
794
+ const ok = await opts.confirmar({
795
+ fichero: file,
796
+ previo: content,
797
+ siguiente,
798
+ resumen: resumenDelCambio(content, siguiente, file),
799
+ });
800
+ if (!ok)
801
+ return 'skipped';
802
+ }
803
+ await writeFile(file, siguiente);
804
+ return resultado;
805
+ };
538
806
  if (content === null) {
539
807
  if (opts.refreshOnly)
540
808
  return 'skipped';
541
- await writeFile(file, section + '\n');
542
- return 'created';
809
+ return escribir(section + '\n', 'created');
543
810
  }
544
811
  // Migración del renombrado (AppAtlas → ChangeBook): si el archivo aún tiene
545
812
  // el bloque con los marcadores antiguos, elimínalo antes de upsertar el
@@ -567,13 +834,13 @@ export async function upsertSection(file, section, opts = {}) {
567
834
  const next = content.slice(0, start) + section + content.slice(end + END.length);
568
835
  // Idempotente: si el mapa no cambió, no tocar el archivo — reescribirlo
569
836
  // actualizaría el mtime, ensuciaría git status y podría invalidar cachés.
837
+ // Y NO SE PREGUNTA cuando no hay nada que cambiar: una pregunta cuya única
838
+ // respuesta útil es «sí, no hagas nada» es ruido que enseña a decir que sí.
570
839
  if (next === content)
571
840
  return 'unchanged';
572
- await writeFile(file, next);
573
- return 'updated';
841
+ return escribir(next, 'updated');
574
842
  }
575
843
  const separator = content.endsWith('\n') ? '\n' : '\n\n';
576
- await writeFile(file, content + separator + section + '\n');
577
- return 'appended';
844
+ return escribir(content + separator + section + '\n', 'appended');
578
845
  }
579
846
  //# sourceMappingURL=sync.js.map