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/aciertos.js +100 -0
- package/dist/aliasDeModulo.js +203 -0
- package/dist/badge.js +159 -0
- package/dist/context.js +7 -1
- 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 +291 -24
- package/dist/tools.js +182 -114
- 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
|
|
@@ -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 =
|
|
125
|
+
const projectId = projectIdOf(projectFilter);
|
|
99
126
|
const since = new Date(Date.now() - ALERT_WINDOW_DAYS * 24 * 3600 * 1000).toISOString();
|
|
100
|
-
const [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
363
|
-
|
|
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 nº 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 === '
|
|
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 === '
|
|
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
|
|
465
|
-
if (!
|
|
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
|
|
491
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
573
|
-
return 'updated';
|
|
841
|
+
return escribir(next, 'updated');
|
|
574
842
|
}
|
|
575
843
|
const separator = content.endsWith('\n') ? '\n' : '\n\n';
|
|
576
|
-
|
|
577
|
-
return 'appended';
|
|
844
|
+
return escribir(content + separator + section + '\n', 'appended');
|
|
578
845
|
}
|
|
579
846
|
//# sourceMappingURL=sync.js.map
|