changebook 0.4.9 → 0.5.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/analyze.js +2 -2
- package/dist/git.js +44 -1
- package/dist/guard.js +419 -31
- package/dist/impact.js +633 -32
- package/dist/import.js +2 -2
- package/dist/rama.js +139 -0
- package/dist/sync.js +138 -20
- package/dist/tools.js +79 -6
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/impact.js
CHANGED
|
@@ -41,8 +41,9 @@ import { spawn } from "node:child_process";
|
|
|
41
41
|
import * as fs from "node:fs";
|
|
42
42
|
import * as path from "node:path";
|
|
43
43
|
import { presupuestoDeTiempo } from "./context.js";
|
|
44
|
-
import { execFileAsync, gitPath } from "./git.js";
|
|
45
|
-
import { avisoRefutado, contarEnRepo, slugifyProject, } from "./guard.js";
|
|
44
|
+
import { execFileAsync, gitPath, projectNameFor } from "./git.js";
|
|
45
|
+
import { avisoRefutado, contarEnRepo, dondeApareceElSimbolo, dondeComprobarlo, entregaDe, ficherosEnRepo, hashDelTexto, headShaDe, slugifyProject, unoPorTexto, } from "./guard.js";
|
|
46
|
+
import { coChangePairs } from "./sync.js";
|
|
46
47
|
import { computeRecidivism, dependentsOf } from "./tools.js";
|
|
47
48
|
/**
|
|
48
49
|
* Techo duro del camino crítico. Más corto que el de `context` (2 s) porque
|
|
@@ -52,6 +53,31 @@ import { computeRecidivism, dependentsOf } from "./tools.js";
|
|
|
52
53
|
const IMPACT_TIMEOUT_MS = 1_200;
|
|
53
54
|
/** Igual que el guardián: las alertas se mueven a velocidad de análisis. */
|
|
54
55
|
const CACHE_TTL_MS = 5 * 60_000;
|
|
56
|
+
/**
|
|
57
|
+
* LA VERSIÓN DE LA FORMA DE LA CACHÉ. Súbela cuando cambie QUÉ se guarda: las
|
|
58
|
+
* columnas de un select, un campo nuevo del que dependa el render, una clave de
|
|
59
|
+
* agrupación. No cuando cambie el código alrededor.
|
|
60
|
+
*
|
|
61
|
+
* Sin esto, una caché escrita por el binario ANTERIOR se deserializa sin error y
|
|
62
|
+
* se sirve entera durante los 5 minutos del TTL, en cada máquina que actualice.
|
|
63
|
+
* No es hipotético: el commit que añadió `evidence_scope` a este mismo select
|
|
64
|
+
* dejó exactamente esa ventana abierta, y el fallo no se ve —la caché vieja no
|
|
65
|
+
* está corrupta, solo le faltan campos, así que el render calla en vez de
|
|
66
|
+
* romperse. Un silencio se lee igual que "no hay nada que decir".
|
|
67
|
+
*
|
|
68
|
+
* Empieza en 1 y no en 2: toda caché SIN `v` es, por definición, anterior al
|
|
69
|
+
* versionado, así que se descarta pase lo que pase.
|
|
70
|
+
*
|
|
71
|
+
* ── v2 (2026-07-28): `changelog_id` en las filas ────────────────────────────
|
|
72
|
+
*
|
|
73
|
+
* Y aquí es donde esto deja de ser precaución. `coChangePairs` AGRUPA POR
|
|
74
|
+
* `changelog_id`: sin esa columna, todas las filas caen bajo la clave
|
|
75
|
+
* `undefined`, se leen como un único análisis gigantesco, y salen TODOS los
|
|
76
|
+
* módulos acoplados con TODOS. No es un fallo ruidoso — es una caché que
|
|
77
|
+
* responde, con datos que parecen razonables, durante los cinco minutos del
|
|
78
|
+
* TTL, en cada máquina que actualice. Con la versión, esas cachés se tiran.
|
|
79
|
+
*/
|
|
80
|
+
export const IMPACT_CACHE_V = 2;
|
|
55
81
|
/**
|
|
56
82
|
* Ventana del grafo. Espejo de MODULE_GRAPH_WINDOW_ROWS en tools.ts: dos
|
|
57
83
|
* ventanas distintas darían dependientes distintos según se pregunte o se
|
|
@@ -64,8 +90,44 @@ const ALERT_WINDOW_ROWS = 500;
|
|
|
64
90
|
const SEEN_TTL_MS = 30 * 60_000;
|
|
65
91
|
/** Cota del archivo de estado: es una nota, no un historial. */
|
|
66
92
|
const SEEN_MAX = 200;
|
|
93
|
+
/**
|
|
94
|
+
* Versión de la forma del estado "ya avisado". Mismo mecanismo que
|
|
95
|
+
* IMPACT_CACHE_V, con UNA diferencia deliberada: aquí un `v` AUSENTE se acepta.
|
|
96
|
+
*
|
|
97
|
+
* Porque la forma de este fichero no ha cambiado, y decir lo contrario tirando
|
|
98
|
+
* el estado sería afirmar un desajuste que no existe. Un `v` presente y
|
|
99
|
+
* DISTINTO sí se descarta, que es la protección para el día que cambie.
|
|
100
|
+
*
|
|
101
|
+
* Y el reparto de riesgo no es el mismo: una ImpactCache vieja sirve datos
|
|
102
|
+
* equivocados; un SeenState viejo, como mucho, repite un aviso o se calla uno
|
|
103
|
+
* dentro de una sesión. No justifica tirar el estado de todo el mundo hoy.
|
|
104
|
+
*/
|
|
105
|
+
const SEEN_STATE_V = 1;
|
|
67
106
|
/** Cuántos módulos se describen si el archivo pertenece a varios. */
|
|
68
107
|
const MAX_MODULES = 3;
|
|
108
|
+
/**
|
|
109
|
+
* Cuánto de la nota del análisis se sirve.
|
|
110
|
+
*
|
|
111
|
+
* SE SIRVE, y ese es el cambio del 2026-07-26 por la tarde. El hook nacía
|
|
112
|
+
* escueto a propósito —dependientes, alertas, reincidencia— y las notas se
|
|
113
|
+
* quedaron fuera por miedo al ruido. Midiendo se vio el coste de esa decisión:
|
|
114
|
+
* la nota de `Carga y arranque de la aplicación` sobre sync.ts dice «el
|
|
115
|
+
* SYNC_BUDGET_CHARS se rebaja de 3.000 a 2.000 porque la prosa gorda expulsaba
|
|
116
|
+
* el mapa», que es EXACTAMENTE el fallo en el que caí dos veces ese mismo día. Y
|
|
117
|
+
* es lo único que ningún grep puede encontrar: no está en el código, está en la
|
|
118
|
+
* historia del análisis. El canal estaba construido y servía lo que git ya sabe.
|
|
119
|
+
*
|
|
120
|
+
* Acotada, no libre: 320 chars por nota y solo en los módulos que YA se
|
|
121
|
+
* reportan (los que tienen dependientes, alerta abierta o reincidencia). Sin el
|
|
122
|
+
* tope, un archivo de diez módulos volcaría diez párrafos y el hook volvería a
|
|
123
|
+
* ser ruido que se aprende a ignorar.
|
|
124
|
+
*
|
|
125
|
+
* 320 y no 220: con 220, la nota de sync.ts se cortaba en «…porque la prosa
|
|
126
|
+
* gord…», justo antes de la conclusión. Estas notas describen primero y concluyen
|
|
127
|
+
* al final, así que un tope corto se come exactamente la carga útil y deja la
|
|
128
|
+
* paja. Tres notas de 320 son ~250 tokens una vez por archivo y sesión.
|
|
129
|
+
*/
|
|
130
|
+
const MAX_NOTE_CHARS = 320;
|
|
69
131
|
/** Tope de greps de refutación: son síncronos y el techo no los desaloja. */
|
|
70
132
|
const MAX_REFUTACIONES = 3;
|
|
71
133
|
export const IMPACT_HOOK = {
|
|
@@ -139,6 +201,115 @@ export function repoRelative(toplevel, filePath) {
|
|
|
139
201
|
// El atlas guarda rutas de git: siempre con "/", también en Windows.
|
|
140
202
|
return rel.split(path.sep).join("/");
|
|
141
203
|
}
|
|
204
|
+
/**
|
|
205
|
+
* Devuelve los avisos servidos con la identidad de TODAS las alertas que decian
|
|
206
|
+
* lo mismo, no solo la que sobrevivio al colapso por texto.
|
|
207
|
+
*
|
|
208
|
+
* Es la mitad de impacto del arreglo que el guardian ya lleva en
|
|
209
|
+
* `guardFindings`: la PANTALLA colapsa dos frases identicas —sobre dos frases
|
|
210
|
+
* iguales no se puede actuar distinto— pero el REGISTRO no puede, porque su
|
|
211
|
+
* trabajo es la atribucion. Con una sola identidad, el veredicto sobre esta
|
|
212
|
+
* entrega se le colgaria a la alerta equivocada.
|
|
213
|
+
*
|
|
214
|
+
* Se hace aparte y no dentro de `unoPorTexto` a proposito: esa funcion es
|
|
215
|
+
* generica y de presentacion, la comparten tres sitios y no sabe —ni debe— que
|
|
216
|
+
* es un id de alerta.
|
|
217
|
+
*/
|
|
218
|
+
export function conIdentidad(servidas, todas) {
|
|
219
|
+
const idsPorTexto = new Map();
|
|
220
|
+
for (const a of todas) {
|
|
221
|
+
const texto = (a.plain ?? "").trim();
|
|
222
|
+
if (!texto || !a.id)
|
|
223
|
+
continue;
|
|
224
|
+
const lista = idsPorTexto.get(texto) ?? [];
|
|
225
|
+
if (!lista.includes(a.id))
|
|
226
|
+
lista.push(a.id);
|
|
227
|
+
idsPorTexto.set(texto, lista);
|
|
228
|
+
}
|
|
229
|
+
return servidas.map((s) => ({
|
|
230
|
+
...s,
|
|
231
|
+
alertIds: idsPorTexto.get(s.plain.trim()) ?? s.alertIds,
|
|
232
|
+
}));
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* EL TECHO DEL CANAL EMPUJADO.
|
|
236
|
+
*
|
|
237
|
+
* `sync.ts` tiene `SYNC_BUDGET_CHARS` desde hace tiempo, con toda una historia
|
|
238
|
+
* detrás (2.000 → 3.000 → vuelta a 2.000, y el comentario que lo explica:
|
|
239
|
+
* «subir el techo alimenta al que se lo come»). Esto no tenía equivalente: hay
|
|
240
|
+
* `MAX_MODULES` y `MAX_NOTE_CHARS`, pero nada acotaba el TOTAL. Tres módulos con
|
|
241
|
+
* nota larga, sus dependientes, sus alertas y ahora sus co-cambios pasan de dos
|
|
242
|
+
* mil caracteres sin que nada los pare — y si la edición toca varios ficheros,
|
|
243
|
+
* se multiplica.
|
|
244
|
+
*
|
|
245
|
+
* MÁS BAJO QUE EL DEL BLOQUE, y no por simetría: el bloque de `CLAUDE.md` se
|
|
246
|
+
* lee UNA vez por sesión y esto dispara en CADA edición. Un canal que cuesta
|
|
247
|
+
* 2.000 caracteres cuarenta veces por sesión no es un canal, es un impuesto.
|
|
248
|
+
*
|
|
249
|
+
* Y el techo no recorta al tuntún: cae primero la prosa (nota), luego la
|
|
250
|
+
* correlación (co-cambio), luego la estructura (dependientes). La alerta abierta
|
|
251
|
+
* tiene suelo y no cae nunca — si eso se recortara, el canal dejaría de tener
|
|
252
|
+
* motivo para existir.
|
|
253
|
+
*/
|
|
254
|
+
const IMPACT_BUDGET_CHARS = 1_500;
|
|
255
|
+
/**
|
|
256
|
+
* Recorta a `budget` quitando por prioridad descendente, no por orden de
|
|
257
|
+
* aparición.
|
|
258
|
+
*
|
|
259
|
+
* DOS PROPIEDADES QUE NO SE NEGOCIAN:
|
|
260
|
+
*
|
|
261
|
+
* Las alertas abiertas (prio 0) NO se recortan jamás, aunque solas pasen del
|
|
262
|
+
* techo. Es el suelo, y es el mismo argumento que el «suelo del mapa» de
|
|
263
|
+
* sync.ts: un presupuesto de suma cero sin suelo acaba comiéndose justo lo
|
|
264
|
+
* que justifica el canal.
|
|
265
|
+
*
|
|
266
|
+
* CONSECUENCIA, dicha aquí para que nadie la descubra depurando: el texto
|
|
267
|
+
* devuelto PUEDE pasar de `budget`. El techo acota lo discrecional, no el
|
|
268
|
+
* suelo. Si una edición toca módulos con veinte alertas abiertas, se sirven
|
|
269
|
+
* las veinte — un canal que se calla justo cuando hay veinte cosas rotas
|
|
270
|
+
* sería exactamente lo contrario de lo que promete.
|
|
271
|
+
*
|
|
272
|
+
* Una cabecera sin contenido no se queda. `- Module **X**` a secas no dice
|
|
273
|
+
* nada que el agente no tenga delante; servirla sería gastar caracteres en
|
|
274
|
+
* ruido y, peor, parecer que se dijo algo.
|
|
275
|
+
*/
|
|
276
|
+
export function recortarAlPresupuesto(lineas, budget = IMPACT_BUDGET_CHARS) {
|
|
277
|
+
const coste = (xs) => xs.reduce((n, x) => n + x.text.length + 1, 0);
|
|
278
|
+
const dentro = new Set(lineas);
|
|
279
|
+
// De la prioridad más alta (más prescindible) a la más baja, y dentro de cada
|
|
280
|
+
// una por el final: si hay que perder co-cambios, se pierden los últimos, que
|
|
281
|
+
// `coChangePairs` ya ordenó de más fuerte a más débil.
|
|
282
|
+
const prios = [...new Set(lineas.map((l) => l.prio))].sort((a, b) => b - a);
|
|
283
|
+
for (const p of prios) {
|
|
284
|
+
if (p === 0)
|
|
285
|
+
break; // El suelo.
|
|
286
|
+
for (const l of [...lineas].reverse()) {
|
|
287
|
+
if (coste([...dentro]) <= budget)
|
|
288
|
+
break;
|
|
289
|
+
if (l.prio === p)
|
|
290
|
+
dentro.delete(l);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
// Cabeceras huérfanas: un módulo cuyas líneas se fueron todas, y un bloque de
|
|
294
|
+
// fichero que se quedó sin módulos.
|
|
295
|
+
const quedan = lineas.filter((l) => dentro.has(l));
|
|
296
|
+
const util = [];
|
|
297
|
+
for (let i = 0; i < quedan.length; i++) {
|
|
298
|
+
const l = quedan[i];
|
|
299
|
+
if (l.ancla === "modulo" && quedan[i + 1]?.ancla !== undefined)
|
|
300
|
+
continue;
|
|
301
|
+
if (l.ancla === "modulo" && i === quedan.length - 1)
|
|
302
|
+
continue;
|
|
303
|
+
util.push(l);
|
|
304
|
+
}
|
|
305
|
+
const conContenido = util.filter((l, i) => l.ancla !== "bloque" || util[i + 1]?.ancla === "modulo");
|
|
306
|
+
// La línea en blanco entre bloques se pone AQUÍ y no al concatenar: si la
|
|
307
|
+
// pusiera quien llama, un bloque recortado entero dejaría su separador
|
|
308
|
+
// suelto — dos saltos de línea que se leen como "aquí falta algo".
|
|
309
|
+
return conContenido
|
|
310
|
+
.map((l, i) => (l.ancla === "bloque" && i > 0 ? `\n${l.text}` : l.text))
|
|
311
|
+
.join("\n");
|
|
312
|
+
}
|
|
142
313
|
/**
|
|
143
314
|
* ¿Merece este módulo un aviso?
|
|
144
315
|
*
|
|
@@ -158,25 +329,121 @@ export function valeLaPena(m) {
|
|
|
158
329
|
* para el mismo hecho serían dos hechos para él.
|
|
159
330
|
*/
|
|
160
331
|
export function impactText(file, modules) {
|
|
161
|
-
|
|
332
|
+
return lineasDeImpacto(file, modules)
|
|
333
|
+
.map((x) => x.text)
|
|
334
|
+
.join("\n");
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* EL ORDEN DE CAÍDA, y por qué es ese.
|
|
338
|
+
*
|
|
339
|
+
* 0 · ALERTA ABIERTA — algo ya se rompió aquí. Es lo único con suelo: si esto se
|
|
340
|
+
* recorta, el canal deja de tener motivo para existir.
|
|
341
|
+
* 1 · DEPENDIENTES — quién se rompe si tocas esto. No está escrito en ninguna
|
|
342
|
+
* parte del código, así que el agente no puede deducirlo.
|
|
343
|
+
* 2 · CO-CAMBIO — más débil que lo anterior: una correlación, no una arista.
|
|
344
|
+
* 3 · NOTA — contexto, no una orden. Y es lo más caro: hasta MAX_NOTE_CHARS por
|
|
345
|
+
* módulo, o sea que tres módulos con nota se comen casi mil caracteres de
|
|
346
|
+
* prosa antes que ningún hecho.
|
|
347
|
+
*/
|
|
348
|
+
export function lineasDeImpacto(file, modules) {
|
|
349
|
+
const lines = [
|
|
350
|
+
{
|
|
351
|
+
prio: 0,
|
|
352
|
+
ancla: "bloque",
|
|
353
|
+
text: `⚠ ChangeBook — impact radius of ${file}, before you edit it:`,
|
|
354
|
+
},
|
|
355
|
+
];
|
|
162
356
|
for (const m of modules.slice(0, MAX_MODULES)) {
|
|
163
357
|
// El riesgo se nombra solo cuando es alto: ver valeLaPena.
|
|
164
358
|
const alto = m.risk === "high" || m.risk === "hotspot";
|
|
165
|
-
lines.push(
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
359
|
+
lines.push({
|
|
360
|
+
prio: 0,
|
|
361
|
+
ancla: "modulo",
|
|
362
|
+
// La reincidencia viaja EN la cabecera y no en su propia línea, así que
|
|
363
|
+
// no se recorta aparte. El plan la ponía en prioridad 2; se queda porque
|
|
364
|
+
// son ~25 caracteres de señal de peligro pegados a una cabecera de 60 que
|
|
365
|
+
// igualmente se sirve — tirarla ahorraría poco y quitaría lo que más pesa
|
|
366
|
+
// de la línea. Si algún día hay que recortarla, se recorta la cabecera
|
|
367
|
+
// entera con su módulo.
|
|
368
|
+
text: `- Module **${m.module}**` +
|
|
369
|
+
(alto ? ` · risk: ${m.risk}` : "") +
|
|
370
|
+
(m.priorRegressions >= 2
|
|
371
|
+
? ` · ⚠ ${m.priorRegressions} prior regressions`
|
|
372
|
+
: ""),
|
|
373
|
+
});
|
|
170
374
|
if (m.dependents.length > 0) {
|
|
171
|
-
lines.push(
|
|
375
|
+
lines.push({
|
|
376
|
+
prio: 1,
|
|
377
|
+
text: ` - ↘ DEPENDS ON THIS: ${m.dependents.join(", ")} — check these too before you finish`,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
// El co-cambio es HISTORIA, no estructura: «quién llama a quién» sale de un
|
|
381
|
+
// grafo y lo tiene medio mercado; «qué se toca junto en la práctica» solo
|
|
382
|
+
// sale del historial. Va DESPUÉS de los dependientes porque es más débil —
|
|
383
|
+
// una correlación, no una arista declarada— y el orden de las líneas es lo
|
|
384
|
+
// que dice cuál de las dos merece más confianza.
|
|
385
|
+
for (const cc of m.coChanges ?? []) {
|
|
386
|
+
lines.push({
|
|
387
|
+
prio: 2,
|
|
388
|
+
text: ` - ↔ CHANGES WITH: ${cc.module} (together in ${Math.round(cc.rate * 100)}% of its changes) — check it before you finish`,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
for (const a of m.alerts) {
|
|
392
|
+
lines.push({ prio: 0, text: ` - ⚠ OPEN ALERT: ${a.plain}` });
|
|
393
|
+
// La condicion, resuelta. Va en su propia linea y con el simbolo delante
|
|
394
|
+
// para que se lea como un hecho comprobado y no como parte de la prosa del
|
|
395
|
+
// modelo, que es justo la que hedgea.
|
|
396
|
+
//
|
|
397
|
+
// Y DICE LAS DOS LECTURAS a proposito, porque el hecho no distingue: un
|
|
398
|
+
// simbolo que sigue apareciendo cuando se esperaba ausente puede ser una
|
|
399
|
+
// referencia que se quedo sin actualizar (el caso `noscriptFor`, que rompio
|
|
400
|
+
// el build) o un aviso que ya no vale (el caso `registerTools`). Antes se
|
|
401
|
+
// elegia la segunda en silencio y se tiraba el aviso; ahora se le dan al
|
|
402
|
+
// agente el hecho y las dos salidas, que es mas informacion que la que
|
|
403
|
+
// habia en cualquiera de las dos versiones anteriores.
|
|
404
|
+
if (a.donde && a.donde.length > 0) {
|
|
405
|
+
lines.push({
|
|
406
|
+
// Prioridad 0 como su alerta: es la COMPROBACIÓN de lo que se afirma,
|
|
407
|
+
// y servir la afirmación sin ella deja al agente con el condicional
|
|
408
|
+
// que esta línea existe para contestar.
|
|
409
|
+
prio: 0,
|
|
410
|
+
text: ` → CHECKED NOW: "${a.simbolo}" still appears in ${a.donde.join(", ")}` +
|
|
411
|
+
` — alert expects it gone: either a reference was missed, or the alert is stale`,
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
// El caso opuesto: el repo NO puede contestar esta afirmacion. Se dice, en
|
|
415
|
+
// vez de dejar que el agente (o el refutador) la compruebe donde no era —
|
|
416
|
+
// que es lo que tiraba las dos alertas del desfase de esquema.
|
|
417
|
+
if (a.fueraDelRepo) {
|
|
418
|
+
lines.push({
|
|
419
|
+
prio: 0,
|
|
420
|
+
text: ` → NOT CHECKABLE HERE: ${a.fueraDelRepo}`,
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
// LA LINEA que lo provocó. Prioridad 0 como su aviso: es la evidencia, y
|
|
424
|
+
// un aviso servido sin ella obliga al agente a reconstruir de qué se
|
|
425
|
+
// habla — que es el trabajo que este canal existe para ahorrarle.
|
|
426
|
+
if (a.linea) {
|
|
427
|
+
lines.push({ prio: 0, text: ` → TRIGGERED BY: ${a.linea}` });
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
// Con fecha y con la misma etiqueta que atlas_file_context: una nota es una
|
|
431
|
+
// observación fechada, no estado vigente. Va DESPUÉS de dependientes y
|
|
432
|
+
// alertas porque es contexto, no una orden.
|
|
433
|
+
if (m.note) {
|
|
434
|
+
lines.push({
|
|
435
|
+
prio: 3,
|
|
436
|
+
text: ` - Note from last analysis${m.noteDate ? ` (${m.noteDate})` : ""}: ${m.note}`,
|
|
437
|
+
});
|
|
172
438
|
}
|
|
173
|
-
for (const plain of m.alerts)
|
|
174
|
-
lines.push(` - ⚠ OPEN ALERT: ${plain}`);
|
|
175
439
|
}
|
|
176
440
|
if (modules.length > MAX_MODULES) {
|
|
177
|
-
lines.push(
|
|
441
|
+
lines.push({
|
|
442
|
+
prio: 3,
|
|
443
|
+
text: `- (+${modules.length - MAX_MODULES} more module(s) affected)`,
|
|
444
|
+
});
|
|
178
445
|
}
|
|
179
|
-
return lines
|
|
446
|
+
return lines;
|
|
180
447
|
}
|
|
181
448
|
/**
|
|
182
449
|
* Rutas que ya se avisaron en ESTA sesión y siguen frescas. Sesión distinta →
|
|
@@ -186,19 +453,27 @@ export function impactText(file, modules) {
|
|
|
186
453
|
export function rutasYaAvisadas(state, sessionId, ahora) {
|
|
187
454
|
if (!state || state.session_id !== sessionId)
|
|
188
455
|
return new Set();
|
|
456
|
+
// Otra forma de fichero: no se interpreta, se olvida. Ausente cuenta como la
|
|
457
|
+
// actual (ver SEEN_STATE_V).
|
|
458
|
+
if ((state.v ?? SEEN_STATE_V) !== SEEN_STATE_V)
|
|
459
|
+
return new Set();
|
|
189
460
|
const vivas = Object.entries(state.seen ?? {}).filter(([, at]) => typeof at === "number" && ahora - at < SEEN_TTL_MS);
|
|
190
461
|
return new Set(vivas.map(([ruta]) => ruta));
|
|
191
462
|
}
|
|
192
463
|
/** El estado siguiente, podado por TTL y por tamaño (más recientes primero). */
|
|
193
464
|
export function estadoSiguiente(state, sessionId, nuevas, ahora) {
|
|
194
|
-
const base = state &&
|
|
465
|
+
const base = state &&
|
|
466
|
+
state.session_id === sessionId &&
|
|
467
|
+
(state.v ?? SEEN_STATE_V) === SEEN_STATE_V
|
|
468
|
+
? { ...(state.seen ?? {}) }
|
|
469
|
+
: {};
|
|
195
470
|
for (const ruta of nuevas)
|
|
196
471
|
base[ruta] = ahora;
|
|
197
472
|
const podado = Object.entries(base)
|
|
198
473
|
.filter(([, at]) => typeof at === "number" && ahora - at < SEEN_TTL_MS)
|
|
199
474
|
.sort((a, b) => b[1] - a[1])
|
|
200
475
|
.slice(0, SEEN_MAX);
|
|
201
|
-
return { session_id: sessionId, seen: Object.fromEntries(podado) };
|
|
476
|
+
return { session_id: sessionId, seen: Object.fromEntries(podado), v: SEEN_STATE_V };
|
|
202
477
|
}
|
|
203
478
|
/**
|
|
204
479
|
* Módulos a los que pertenece un archivo, del más reciente al más antiguo.
|
|
@@ -216,14 +491,200 @@ export function modulosDelArchivo(file, rows) {
|
|
|
216
491
|
if (!label || out.has(label))
|
|
217
492
|
continue;
|
|
218
493
|
const files = Array.isArray(row.files) ? row.files.map(String) : [];
|
|
219
|
-
if (files.includes(file))
|
|
220
|
-
|
|
494
|
+
if (!files.includes(file))
|
|
495
|
+
continue;
|
|
496
|
+
const nota = (row.note ?? "").trim();
|
|
497
|
+
out.set(label, {
|
|
498
|
+
risk: row.risk,
|
|
499
|
+
note: nota
|
|
500
|
+
? nota.length > MAX_NOTE_CHARS
|
|
501
|
+
? `${nota.slice(0, MAX_NOTE_CHARS - 1).trimEnd()}…`
|
|
502
|
+
: nota
|
|
503
|
+
: null,
|
|
504
|
+
noteDate: (row.created_at ?? "").slice(0, 10) || null,
|
|
505
|
+
});
|
|
221
506
|
}
|
|
222
|
-
return [...out.entries()].map(([module,
|
|
507
|
+
return [...out.entries()].map(([module, v]) => ({ module, ...v }));
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* ¿Se puede servir esta caché? Fresca Y de esta forma.
|
|
511
|
+
*
|
|
512
|
+
* Exportada para poder probar el descarte sin montar un repo: es una función
|
|
513
|
+
* pura y el fallo que evita —servir campos que no están— no se ve desde fuera.
|
|
514
|
+
*/
|
|
515
|
+
export function cacheServible(cached, ahora) {
|
|
516
|
+
if (!cached)
|
|
517
|
+
return false;
|
|
518
|
+
if (cached.v !== IMPACT_CACHE_V)
|
|
519
|
+
return false;
|
|
520
|
+
return ahora - cached.fetched_at < CACHE_TTL_MS;
|
|
223
521
|
}
|
|
224
522
|
async function cachePath(dir) {
|
|
225
523
|
return gitPath(dir, "changebook-impact-cache.json").catch(() => null);
|
|
226
524
|
}
|
|
525
|
+
// ── El libro de entregas del canal empujado ──────────────────────────────────
|
|
526
|
+
//
|
|
527
|
+
// El hook PreToolUse es el canal MÁS frecuente del atlas —dispara en cada
|
|
528
|
+
// edición— y hasta hoy servía consejo sin dejar rastro: "¿acertó el aviso?" solo
|
|
529
|
+
// se podía responder del guardián pre-commit, que es el que menos corre.
|
|
530
|
+
//
|
|
531
|
+
// EL DISEÑO LO COMPLICA A PROPÓSITO, y hay que respetarlo: el camino crítico de
|
|
532
|
+
// `printImpact` no toca la red JAMÁS (ver `atlasSignals`) y tiene techo de
|
|
533
|
+
// 1.200 ms. Un insert síncrono rompería la regla que sostiene todo esto — el
|
|
534
|
+
// hook nunca puede costar tiempo perceptible.
|
|
535
|
+
//
|
|
536
|
+
// Por eso la entrega se ANOTA en disco (un append, sin leer ni parsear nada) y
|
|
537
|
+
// la sirve al servidor `impact --warm`, que ya corre desacoplado y ya toca la
|
|
538
|
+
// red. Escribir y enviar quedan separados por un proceso: si el editor mata al
|
|
539
|
+
// hook entre las dos cosas, la línea sigue en el fichero y se manda en la
|
|
540
|
+
// siguiente ronda.
|
|
541
|
+
/** Cota de la cola. Mismo patrón que el log del guardián: por tamaño, no por olvido. */
|
|
542
|
+
const QUEUE_MAX_BYTES = 65_536;
|
|
543
|
+
const QUEUE_KEEP_BYTES = 32_768;
|
|
544
|
+
async function queuePath(dir) {
|
|
545
|
+
return gitPath(dir, "changebook-impact-queue.jsonl").catch(() => null);
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Anota una entrega. JSONL y no JSON: un append no tiene que leer lo que ya hay,
|
|
549
|
+
* y dos procesos solapados (dos ediciones seguidas) no pueden pisarse el fichero
|
|
550
|
+
* como sí harían leyendo-modificando-escribiendo un array.
|
|
551
|
+
*
|
|
552
|
+
* Best-effort de principio a fin: registrar la entrega no puede romper la
|
|
553
|
+
* edición que la provocó.
|
|
554
|
+
*/
|
|
555
|
+
export function encolarEntrega(file, entrega) {
|
|
556
|
+
if (!file)
|
|
557
|
+
return;
|
|
558
|
+
try {
|
|
559
|
+
// Recortar ANTES de anexar, igual que el log del guardián. El recorte parte
|
|
560
|
+
// por la mitad de una línea a propósito: reconstruirla costaría leer y
|
|
561
|
+
// parsear el fichero entero en el camino crítico, y la línea rota se tira
|
|
562
|
+
// sola al drenar (ver `entregasDeLaCola`). Se pierde UNA entrega de hace
|
|
563
|
+
// rato antes que gastar milisegundos de cada edición.
|
|
564
|
+
try {
|
|
565
|
+
if (fs.statSync(file).size > QUEUE_MAX_BYTES) {
|
|
566
|
+
const keep = fs.readFileSync(file).subarray(-QUEUE_KEEP_BYTES);
|
|
567
|
+
fs.writeFileSync(file, keep);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
catch {
|
|
571
|
+
// Aún no existe: nada que recortar.
|
|
572
|
+
}
|
|
573
|
+
fs.appendFileSync(file, `${JSON.stringify(entrega)}\n`);
|
|
574
|
+
}
|
|
575
|
+
catch {
|
|
576
|
+
// Sin sitio donde anotar, el hook sigue avisando igual. El registro es una
|
|
577
|
+
// segunda lectura del mismo hecho, nunca el hecho.
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* Las entregas legibles de la cola. Las líneas rotas se tiran en silencio: la
|
|
582
|
+
* primera puede venir cortada por el recorte, y una entrega ilegible no es un
|
|
583
|
+
* error del que haya que informar a nadie — es una que no se pudo registrar.
|
|
584
|
+
*/
|
|
585
|
+
export function entregasDeLaCola(contenido) {
|
|
586
|
+
const fuera = [];
|
|
587
|
+
for (const linea of contenido.split("\n")) {
|
|
588
|
+
if (!linea.trim())
|
|
589
|
+
continue;
|
|
590
|
+
try {
|
|
591
|
+
const e = JSON.parse(linea);
|
|
592
|
+
// El CHECK de la migración exige sha256 en hex; una fila que no lo cumpla
|
|
593
|
+
// haría fallar el insert de TODAS las demás si fueran en lote.
|
|
594
|
+
if (typeof e?.advice_hash === "string" && /^[0-9a-f]{64}$/.test(e.advice_hash)) {
|
|
595
|
+
fuera.push(e);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
catch {
|
|
599
|
+
// Línea partida por el recorte, o basura. Se ignora.
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
return fuera;
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Las que TODAVÍA no están registradas, por (advice_hash, head_sha).
|
|
606
|
+
*
|
|
607
|
+
* Es la respuesta a "y si el proceso muere entre insertar y vaciar la cola":
|
|
608
|
+
* la cola se vacía DESPUÉS de insertar, así que morir en medio deja las líneas
|
|
609
|
+
* puestas y la siguiente ronda las volvería a mandar. Preguntar primero cuáles
|
|
610
|
+
* ya están hace que reenviar sea gratis, que es la misma propiedad que ya tiene
|
|
611
|
+
* `atlas_record_change`.
|
|
612
|
+
*
|
|
613
|
+
* Pura para poder probar el caso sin red, que es el que no se ve venir.
|
|
614
|
+
*/
|
|
615
|
+
export function entregasSinRegistrar(cola, yaRegistradas) {
|
|
616
|
+
const clave = (h, s) => `${h ?? ""}\u0000${s ?? ""}`;
|
|
617
|
+
const vistas = new Set(yaRegistradas.map((r) => clave(r.advice_hash, r.head_sha)));
|
|
618
|
+
const fuera = [];
|
|
619
|
+
for (const e of cola) {
|
|
620
|
+
const k = clave(e.advice_hash, e.head_sha);
|
|
621
|
+
// Contra el servidor Y contra sí misma: la misma edición repetida dentro de
|
|
622
|
+
// la cola es una sola entrega, no dos.
|
|
623
|
+
if (vistas.has(k))
|
|
624
|
+
continue;
|
|
625
|
+
vistas.add(k);
|
|
626
|
+
fuera.push(e);
|
|
627
|
+
}
|
|
628
|
+
return fuera;
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* Vacía la cola contra `atlas_reads`. Corre en `impact --warm`, o sea en un
|
|
632
|
+
* proceso aparte del que edita y que ya paga la red de todas formas.
|
|
633
|
+
*
|
|
634
|
+
* NO se drena también en `changebook guard`: el warm corre en cuanto la caché
|
|
635
|
+
* caduca (5 minutos), así que durante una sesión de edición la cola se vacía
|
|
636
|
+
* sola una y otra vez. Lo que queda sin mandar al cerrar el editor sigue en el
|
|
637
|
+
* fichero y se manda en la siguiente sesión — se retrasa, no se pierde.
|
|
638
|
+
*/
|
|
639
|
+
async function drenarCola(db, dir) {
|
|
640
|
+
const file = await queuePath(dir);
|
|
641
|
+
if (!file)
|
|
642
|
+
return;
|
|
643
|
+
let contenido;
|
|
644
|
+
try {
|
|
645
|
+
contenido = fs.readFileSync(file, "utf8");
|
|
646
|
+
}
|
|
647
|
+
catch {
|
|
648
|
+
return; // No hay cola: el caso normal.
|
|
649
|
+
}
|
|
650
|
+
const cola = entregasDeLaCola(contenido);
|
|
651
|
+
if (cola.length === 0) {
|
|
652
|
+
try {
|
|
653
|
+
fs.rmSync(file, { force: true });
|
|
654
|
+
}
|
|
655
|
+
catch {
|
|
656
|
+
/* da igual */
|
|
657
|
+
}
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
const hashes = [...new Set(cola.map((e) => e.advice_hash))];
|
|
661
|
+
const yaRegistradas = await db
|
|
662
|
+
.rest(`atlas_reads?select=advice_hash,head_sha&advice_hash=in.(${hashes.join(",")})&limit=${hashes.length * 4}`)
|
|
663
|
+
.catch(() => []);
|
|
664
|
+
const pendientes = entregasSinRegistrar(cola, yaRegistradas);
|
|
665
|
+
for (const e of pendientes) {
|
|
666
|
+
const { project_id, chars_served, ...entrega } = e;
|
|
667
|
+
// Una a una y no en lote: un insert que falle no puede llevarse por delante
|
|
668
|
+
// las otras entregas, que ya no estarían en ningún sitio.
|
|
669
|
+
await db
|
|
670
|
+
.insertRow("atlas_reads", {
|
|
671
|
+
project_id,
|
|
672
|
+
tool: "impact_pretooluse",
|
|
673
|
+
source: "stdio",
|
|
674
|
+
chars_served,
|
|
675
|
+
...entrega,
|
|
676
|
+
})
|
|
677
|
+
.catch(() => { });
|
|
678
|
+
}
|
|
679
|
+
// Se vacía DESPUÉS. Si el proceso muere aquí, la siguiente ronda pregunta y
|
|
680
|
+
// no duplica (ver `entregasSinRegistrar`).
|
|
681
|
+
try {
|
|
682
|
+
fs.rmSync(file, { force: true });
|
|
683
|
+
}
|
|
684
|
+
catch {
|
|
685
|
+
// La cola se recortará sola por tamaño; el dedup impide que se repitan.
|
|
686
|
+
}
|
|
687
|
+
}
|
|
227
688
|
function leerJson(file) {
|
|
228
689
|
if (!file)
|
|
229
690
|
return null;
|
|
@@ -247,7 +708,7 @@ function escribirJson(file, data) {
|
|
|
247
708
|
async function fetchAtlas(db, dir, env) {
|
|
248
709
|
// Mismo proyecto al que reporta analyze/hook: CHANGEBOOK_PROJECT manda, si no
|
|
249
710
|
// el nombre del directorio, casado por slug del servidor primero.
|
|
250
|
-
const candidate =
|
|
711
|
+
const candidate = projectNameFor(dir, env);
|
|
251
712
|
const slug = slugifyProject(candidate);
|
|
252
713
|
let projects = slug
|
|
253
714
|
? await db.rest(`projects?select=id&slug=eq.${encodeURIComponent(slug)}&limit=1`)
|
|
@@ -257,7 +718,14 @@ async function fetchAtlas(db, dir, env) {
|
|
|
257
718
|
}
|
|
258
719
|
const projectId = projects[0]?.id ?? null;
|
|
259
720
|
if (!projectId) {
|
|
260
|
-
return {
|
|
721
|
+
return {
|
|
722
|
+
v: IMPACT_CACHE_V,
|
|
723
|
+
fetched_at: Date.now(),
|
|
724
|
+
project_id: null,
|
|
725
|
+
rows: [],
|
|
726
|
+
deps: [],
|
|
727
|
+
alerts: [],
|
|
728
|
+
};
|
|
261
729
|
}
|
|
262
730
|
// TRES consultas en paralelo, una vez cada 5 minutos y fuera del camino
|
|
263
731
|
// crítico, así que el número de rondas aquí da igual. Lo que NO da igual es
|
|
@@ -275,16 +743,34 @@ async function fetchAtlas(db, dir, env) {
|
|
|
275
743
|
// reincidencia (all-time) salen del mismo conjunto.
|
|
276
744
|
const [rows, deps, alerts] = await Promise.all([
|
|
277
745
|
db
|
|
278
|
-
.rest(
|
|
746
|
+
.rest(
|
|
747
|
+
// `changelog_id` para el co-cambio (ver ImpactCache["rows"]). Va en la
|
|
748
|
+
// consulta que YA se hacía, no en una cuarta: el co-cambio no puede
|
|
749
|
+
// costar una ronda de red más en el canal que corre en cada edición.
|
|
750
|
+
`change_module?select=module,files,risk,note,created_at,changelog_id&project_id=eq.${projectId}&order=created_at.desc&limit=${GRAPH_WINDOW_ROWS}`)
|
|
279
751
|
.catch(() => []),
|
|
280
752
|
db
|
|
281
753
|
.rest(`change_module?select=module,deps&project_id=eq.${projectId}&deps=not.is.null&order=created_at.desc&limit=${GRAPH_WINDOW_ROWS}`)
|
|
282
754
|
.catch(() => []),
|
|
283
755
|
db
|
|
284
|
-
.rest(
|
|
756
|
+
.rest(
|
|
757
|
+
// `evidence_scope` decide si el grep de refutación significa algo (ver
|
|
758
|
+
// el comentario del mismo select en guard.ts). `id` y `created_at` no
|
|
759
|
+
// los lee nadie TODAVÍA: son la identidad de la alerta, que es lo que
|
|
760
|
+
// hace falta para poder atribuir después "se te avisó de esto" a la fila
|
|
761
|
+
// que lo dijo. Vienen en la misma consulta porque son gratis aquí y una
|
|
762
|
+
// caché escrita sin ellos sobreviviría 5 minutos a quien los espere.
|
|
763
|
+
`regression_alerts?select=id,module,plain,created_at,files,resolved_at,evidence_symbol,evidence_expect,evidence_scope,evidence_line&project_id=eq.${projectId}&order=created_at.desc&limit=${ALERT_WINDOW_ROWS}`)
|
|
285
764
|
.catch(() => []),
|
|
286
765
|
]);
|
|
287
|
-
return {
|
|
766
|
+
return {
|
|
767
|
+
v: IMPACT_CACHE_V,
|
|
768
|
+
fetched_at: Date.now(),
|
|
769
|
+
project_id: projectId,
|
|
770
|
+
rows,
|
|
771
|
+
deps,
|
|
772
|
+
alerts,
|
|
773
|
+
};
|
|
288
774
|
}
|
|
289
775
|
/**
|
|
290
776
|
* Las señales, SIEMPRE de disco. Nunca de la red.
|
|
@@ -310,7 +796,10 @@ async function atlasSignals(db, dir) {
|
|
|
310
796
|
if (!file)
|
|
311
797
|
return null;
|
|
312
798
|
const cached = leerJson(file);
|
|
313
|
-
|
|
799
|
+
// Una caché de otra versión NO se sirve y además dispara el recalentado, que
|
|
800
|
+
// es lo mismo que hace una caducada: en las dos, lo que hay en disco no
|
|
801
|
+
// responde a la pregunta que se está haciendo.
|
|
802
|
+
if (cacheServible(cached, Date.now()))
|
|
314
803
|
return cached;
|
|
315
804
|
if (db.hasCredentials())
|
|
316
805
|
calentarDesacoplado(dir);
|
|
@@ -348,12 +837,32 @@ export async function warmImpactCache(db, dir, env = process.env) {
|
|
|
348
837
|
const file = await cachePath(dir);
|
|
349
838
|
if (!file)
|
|
350
839
|
return;
|
|
840
|
+
// El libro de entregas ANTES de la puerta de frescura: si se pusiera
|
|
841
|
+
// después, una caché todavía fresca haría volver a este proceso por donde
|
|
842
|
+
// vino y la cola no se vaciaría nunca. El fallo sería invisible —el hook
|
|
843
|
+
// seguiría avisando igual— y "¿acertó el aviso?" seguiría sin respuesta por
|
|
844
|
+
// el canal más frecuente, que es justo lo que esto viene a arreglar.
|
|
845
|
+
await drenarCola(db, dir);
|
|
351
846
|
// Carrera entre dos ediciones seguidas: si otro proceso ya dejó una caché
|
|
352
847
|
// fresca mientras este arrancaba, no gastar dos veces la misma consulta.
|
|
353
848
|
const cached = leerJson(file);
|
|
354
|
-
if (cached
|
|
849
|
+
if (cacheServible(cached, Date.now()))
|
|
850
|
+
return;
|
|
851
|
+
const fresco = await fetchAtlas(db, dir, env);
|
|
852
|
+
// Un calentamiento que NO resolvió el proyecto no se cachea.
|
|
853
|
+
//
|
|
854
|
+
// Cazado el 2026-07-26 montando el banco de pruebas: `impact --warm` sin
|
|
855
|
+
// CHANGEBOOK_PROJECT en un directorio cuyo nombre no es el del proyecto
|
|
856
|
+
// guardaba `{project_id: null, rows: []}`, y `atlasSignals` lo tomaba por
|
|
857
|
+
// caché válida durante CACHE_TTL_MS. Resultado: cinco minutos de silencio
|
|
858
|
+
// que se leen EXACTAMENTE igual que "no hay nada que decir" — la clase de
|
|
859
|
+
// fallo que este producto existe para cazar, dentro del producto.
|
|
860
|
+
//
|
|
861
|
+
// Sin cachearlo, el siguiente intento vuelve a preguntar. Cuesta un
|
|
862
|
+
// calentamiento desacoplado más; callar cinco minutos cuesta el canal.
|
|
863
|
+
if (!fresco.project_id)
|
|
355
864
|
return;
|
|
356
|
-
escribirJson(file,
|
|
865
|
+
escribirJson(file, fresco);
|
|
357
866
|
}
|
|
358
867
|
catch {
|
|
359
868
|
// Nadie está mirando esta salida; el síntoma de un fallo aquí es que no hay
|
|
@@ -417,16 +926,46 @@ async function buildImpact(db, payload) {
|
|
|
417
926
|
// archivo con muchas alertas vivas podría comerse el presupuesto entero a
|
|
418
927
|
// 30 ms por grep. Tres es lo que cabe de sobra; el resto pasa sin refutar,
|
|
419
928
|
// que es el lado seguro (la duda deja pasar el aviso).
|
|
929
|
+
//
|
|
930
|
+
// El presupuesto lo comparten refutar y localizar, porque son el mismo grep
|
|
931
|
+
// sobre el mismo simbolo y solo uno de los dos aplica a cada aviso: 'present'
|
|
932
|
+
// se refuta, 'absent' se localiza (ver avisoRefutado). Nunca se gastan dos.
|
|
420
933
|
let grepsRestantes = MAX_REFUTACIONES;
|
|
421
934
|
const refutada = (a) => {
|
|
935
|
+
if (a.evidence_expect !== "present")
|
|
936
|
+
return false;
|
|
422
937
|
if (grepsRestantes <= 0)
|
|
423
938
|
return false;
|
|
424
939
|
grepsRestantes -= 1;
|
|
425
940
|
return avisoRefutado(a, (s) => contarEnRepo(toplevel, s));
|
|
426
941
|
};
|
|
942
|
+
/** Donde sigue apareciendo el simbolo de un aviso 'absent'. Contesta el condicional. */
|
|
943
|
+
const localizar = (a) => {
|
|
944
|
+
if (a.evidence_expect !== "absent")
|
|
945
|
+
return null;
|
|
946
|
+
if (grepsRestantes <= 0)
|
|
947
|
+
return null;
|
|
948
|
+
grepsRestantes -= 1;
|
|
949
|
+
return dondeApareceElSimbolo(a, (s) => ficherosEnRepo(toplevel, s));
|
|
950
|
+
};
|
|
427
951
|
const depsRows = signals.deps ?? [];
|
|
428
|
-
|
|
952
|
+
// Co-cambio: UNA vez por corrida, no por módulo. `coChangePairs` recorre la
|
|
953
|
+
// ventana entera y ya trae sus propios umbrales (MIN_PAIR_COUNT=3,
|
|
954
|
+
// MIN_PAIR_RATE=0.6, MAX_COUPLINGS=5) y su exclusión de módulos de pruebas.
|
|
955
|
+
// Se importa de sync.ts en vez de copiarla: es la MISMA pregunta que contesta
|
|
956
|
+
// el bloque de CLAUDE.md, y dos respuestas distintas al mismo hecho es lo que
|
|
957
|
+
// enseña al agente a no fiarse de ninguna.
|
|
958
|
+
const parejas = coChangePairs(signals.rows.filter((r) => typeof r.changelog_id === "string" && r.changelog_id.length > 0));
|
|
959
|
+
/** Con quién cambia junto un módulo, mirando la pareja por los dos lados. */
|
|
960
|
+
const coCambiosDe = (modulo) => parejas
|
|
961
|
+
.filter((p) => p.a === modulo || p.b === modulo)
|
|
962
|
+
.map((p) => ({ module: p.a === modulo ? p.b : p.a, rate: p.rate }));
|
|
963
|
+
// Lineas con prioridad, no texto ya cocido: el techo se aplica al TOTAL, asi
|
|
964
|
+
// que hay que poder recortar despues de saber cuanto ocupa todo junto.
|
|
965
|
+
const lineas = [];
|
|
429
966
|
const avisadas = [];
|
|
967
|
+
/** Las alertas que de verdad salieron en el texto. Ver el encolado del final. */
|
|
968
|
+
const idsServidos = [];
|
|
430
969
|
for (const file of pendientes) {
|
|
431
970
|
const modulos = modulosDelArchivo(file, signals.rows);
|
|
432
971
|
if (modulos.length === 0)
|
|
@@ -436,25 +975,87 @@ async function buildImpact(db, payload) {
|
|
|
436
975
|
.map((m) => ({
|
|
437
976
|
module: m.module,
|
|
438
977
|
risk: m.risk,
|
|
978
|
+
note: m.note,
|
|
979
|
+
noteDate: m.noteDate,
|
|
439
980
|
dependents: dependientes.get(m.module) ?? [],
|
|
440
|
-
|
|
981
|
+
coChanges: coCambiosDe(m.module),
|
|
982
|
+
// Una linea por texto: dos avisos con la MISMA frase no le dejan al
|
|
983
|
+
// agente hacer nada distinto, por mucho que por dentro sean
|
|
984
|
+
// afirmaciones opuestas. Gana el que trae linea de comprobacion.
|
|
985
|
+
alerts: conIdentidad(unoPorTexto(abiertas
|
|
441
986
|
.filter((a) => (a.module ?? "").trim() === m.module && a.plain)
|
|
442
987
|
.filter((a) => !refutada(a))
|
|
443
|
-
.map((a) =>
|
|
988
|
+
.map((a) => {
|
|
989
|
+
const donde = localizar(a);
|
|
990
|
+
const fuera = dondeComprobarlo(a);
|
|
991
|
+
return {
|
|
992
|
+
plain: a.plain,
|
|
993
|
+
alertIds: a.id ? [a.id] : [],
|
|
994
|
+
...(donde
|
|
995
|
+
? { donde, simbolo: (a.evidence_symbol ?? "").trim() }
|
|
996
|
+
: {}),
|
|
997
|
+
...(fuera ? { fueraDelRepo: fuera } : {}),
|
|
998
|
+
...(a.evidence_line
|
|
999
|
+
? { linea: String(a.evidence_line).trim() }
|
|
1000
|
+
: {}),
|
|
1001
|
+
};
|
|
1002
|
+
}), (x) => x.plain, (x) => Boolean(x.donde || x.fueraDelRepo)), abiertas.filter((a) => (a.module ?? "").trim() === m.module)),
|
|
444
1003
|
priorRegressions: recidivism.get(m.module) ?? 0,
|
|
445
1004
|
}))
|
|
446
|
-
.filter(valeLaPena)
|
|
1005
|
+
.filter(valeLaPena)
|
|
1006
|
+
// Por PELIGRO, no por lo más reciente. Solo caben MAX_MODULES y el orden
|
|
1007
|
+
// decide qué se ve: con el orden por recencia, el módulo cuya nota decía
|
|
1008
|
+
// «la prosa gorda expulsaba el mapa» —6 regresiones previas, y el fallo
|
|
1009
|
+
// exacto en el que caí dos veces el 2026-07-26— quedaba en el «+N more».
|
|
1010
|
+
// Servir la nota no vale nada si la nota que importa no entra.
|
|
1011
|
+
//
|
|
1012
|
+
// Total y determinista: el desempate por nombre mantiene el string estable
|
|
1013
|
+
// entre sesiones, que es lo que evita pagar una escritura de caché a 1,25×
|
|
1014
|
+
// en vez de una lectura a 0,1×.
|
|
1015
|
+
.sort((a, b) => b.alerts.length - a.alerts.length ||
|
|
1016
|
+
b.priorRegressions - a.priorRegressions ||
|
|
1017
|
+
b.dependents.length - a.dependents.length ||
|
|
1018
|
+
a.module.localeCompare(b.module));
|
|
447
1019
|
if (impactos.length === 0)
|
|
448
1020
|
continue;
|
|
449
|
-
|
|
1021
|
+
lineas.push(...lineasDeImpacto(file, impactos));
|
|
450
1022
|
avisadas.push(file);
|
|
1023
|
+
for (const m of impactos.slice(0, MAX_MODULES)) {
|
|
1024
|
+
idsServidos.push(...m.alerts.flatMap((a) => a.alertIds));
|
|
1025
|
+
}
|
|
451
1026
|
}
|
|
452
1027
|
// Solo se marca como avisado lo que de verdad se dijo: si el archivo no tenía
|
|
453
1028
|
// nada hoy pero mañana sale una alerta suya, el aviso tiene que poder salir.
|
|
454
1029
|
if (avisadas.length > 0) {
|
|
455
1030
|
escribirJson(seenFile, estadoSiguiente(leerJson(seenFile), sessionId, avisadas, ahora));
|
|
456
1031
|
}
|
|
457
|
-
|
|
1032
|
+
if (lineas.length === 0)
|
|
1033
|
+
return null;
|
|
1034
|
+
// EL TECHO, sobre el total y no por fichero: una edición que toca cuatro
|
|
1035
|
+
// archivos servía cuatro bloques enteros, y el coste del canal se multiplicaba
|
|
1036
|
+
// sin que ningún tope lo viera. Ver IMPACT_BUDGET_CHARS.
|
|
1037
|
+
const texto = recortarAlPresupuesto(lineas);
|
|
1038
|
+
if (!texto)
|
|
1039
|
+
return null;
|
|
1040
|
+
// La entrega, anotada en disco. `MAX_MODULES` arriba no es cosmético: los
|
|
1041
|
+
// módulos que caen en el «+N more» NO se sirvieron, así que sus alertas no
|
|
1042
|
+
// pueden entrar en el registro de lo servido.
|
|
1043
|
+
//
|
|
1044
|
+
// headShaDe es un `git rev-parse` síncrono (~5 ms) y cabe de sobra en el
|
|
1045
|
+
// presupuesto; el hash de un texto de dos kilobytes no se mide. Lo que NO
|
|
1046
|
+
// entra aquí es la red: eso es del `--warm`.
|
|
1047
|
+
encolarEntrega(await queuePath(dir), {
|
|
1048
|
+
project_id: signals.project_id,
|
|
1049
|
+
chars_served: texto.length,
|
|
1050
|
+
...entregaDe({
|
|
1051
|
+
alertIds: idsServidos,
|
|
1052
|
+
files: avisadas,
|
|
1053
|
+
message: texto,
|
|
1054
|
+
headSha: headShaDe(dir),
|
|
1055
|
+
hash: hashDelTexto,
|
|
1056
|
+
}),
|
|
1057
|
+
});
|
|
1058
|
+
return texto;
|
|
458
1059
|
}
|
|
459
1060
|
/**
|
|
460
1061
|
* El comando. Falla abierto SIEMPRE: sin credenciales, sin red, sin proyecto,
|