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/aciertos.js +100 -0
- package/dist/aliasDeModulo.js +203 -0
- package/dist/analyze.js +40 -2
- package/dist/badge.js +159 -0
- package/dist/context.js +7 -1
- package/dist/git.js +42 -2
- package/dist/guard.js +96 -13
- package/dist/hookMudo.js +172 -0
- package/dist/impact.js +319 -25
- package/dist/import.js +26 -1
- package/dist/index.js +173 -5
- package/dist/pregunta.js +59 -0
- package/dist/reglas.js +299 -0
- package/dist/scan.js +427 -0
- package/dist/supabase.js +84 -1
- package/dist/sync.js +215 -29
- package/dist/tools.js +182 -44
- package/package.json +2 -2
- package/server.json +3 -3
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 =
|
|
156
|
+
const projectId = projectIdOf(projectFilter);
|
|
99
157
|
const since = new Date(Date.now() - ALERT_WINDOW_DAYS * 24 * 3600 * 1000).toISOString();
|
|
100
|
-
const [
|
|
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
|
-
|
|
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
|
-
//
|
|
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,
|
|
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
|
|
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(
|
|
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
|
-
|
|
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 ?? ''
|
|
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 ?? ''
|
|
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 ?? ''
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
577
|
-
if (!
|
|
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
|
|
603
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
685
|
-
return 'updated';
|
|
872
|
+
return escribir(next, 'updated');
|
|
686
873
|
}
|
|
687
874
|
const separator = content.endsWith('\n') ? '\n' : '\n\n';
|
|
688
|
-
|
|
689
|
-
return 'appended';
|
|
875
|
+
return escribir(content + separator + section + '\n', 'appended');
|
|
690
876
|
}
|
|
691
877
|
//# sourceMappingURL=sync.js.map
|