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/import.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import * as path from "node:path";
|
|
12
12
|
import { atlasWebUrl } from "./browser.js";
|
|
13
|
-
import { commitDiff, execFileAsync, GIT_MAX_BUFFER_BYTES, gitErrorMessage, MAX_DIFF_CHARACTERS, usableSummary, } from "./git.js";
|
|
13
|
+
import { commitDiff, execFileAsync, GIT_MAX_BUFFER_BYTES, gitErrorMessage, MAX_DIFF_CHARACTERS, projectNameFor, usableSummary, } from "./git.js";
|
|
14
14
|
import { canonicalDiffHash } from "./canonical.js";
|
|
15
15
|
import { optimizeTokensForAI } from "./optimize.js";
|
|
16
16
|
// Under the server's MAX_BATCH_ITEMS (25) to leave headroom.
|
|
@@ -29,7 +29,7 @@ const SKIP_FILE_PATTERNS = [
|
|
|
29
29
|
];
|
|
30
30
|
export async function importHistory(db, options = {}) {
|
|
31
31
|
const cwd = path.resolve(options.dir ?? process.cwd());
|
|
32
|
-
const projectName =
|
|
32
|
+
const projectName = projectNameFor(cwd);
|
|
33
33
|
const count = Math.max(1, Math.min(options.commits ?? 25, 100));
|
|
34
34
|
// A previous run may have been interrupted after submitting: the Anthropic
|
|
35
35
|
// batch keeps running server-side, so finish those before spending more.
|
package/dist/rama.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ¿Va tu rama a revertir el trabajo de otro al mergearse?
|
|
3
|
+
*
|
|
4
|
+
* Segunda de las tres comprobaciones del vigilante («¿está mi proyecto como yo
|
|
5
|
+
* creo?»), acordadas el 2026-07-26 eligiéndolas por lo que ya había dolido.
|
|
6
|
+
*
|
|
7
|
+
* ── EL INCIDENTE QUE LA PIDE ────────────────────────────────────────────────
|
|
8
|
+
*
|
|
9
|
+
* Las PRs #358 y #359 hubo que CERRARLAS en vez de mergearlas: sus ramas venían
|
|
10
|
+
* de un `main` viejo y mergearlas habría revertido 667 y 856 líneas. Y el
|
|
11
|
+
* 2026-07-27 pasó otra vez con la #360. Ninguna de las tres avisó de nada — CI
|
|
12
|
+
* en verde las tres, porque los tests pasan perfectamente sobre un árbol viejo.
|
|
13
|
+
*
|
|
14
|
+
* ── LA SEÑAL NO ES "VAS ATRASADO" ───────────────────────────────────────────
|
|
15
|
+
*
|
|
16
|
+
* Estar 50 commits por detrás en ficheros que no tocas es inofensivo, y avisar de
|
|
17
|
+
* eso es el ruido que enseña a ignorar los avisos. Lo que revierte trabajo es el
|
|
18
|
+
* SOLAPAMIENTO: ficheros que ha tocado tu rama Y que main ha tocado desde que os
|
|
19
|
+
* separasteis. Ahí tu versión es más vieja y al mergear gana la tuya.
|
|
20
|
+
*
|
|
21
|
+
* Es la misma lección del guardián de hoy: no avises por el módulo, avisa por el
|
|
22
|
+
* fichero.
|
|
23
|
+
*
|
|
24
|
+
* ── LO QUE NO HACE ──────────────────────────────────────────────────────────
|
|
25
|
+
*
|
|
26
|
+
* No hace `fetch`. Corre en el camino de un commit y una llamada de red ahí es
|
|
27
|
+
* inaceptable, así que mira el `origin/main` que ya tengas. Si no lo actualizas
|
|
28
|
+
* nunca, esto avisa de menos — nunca de más, que es el lado correcto para algo
|
|
29
|
+
* que interrumpe.
|
|
30
|
+
*/
|
|
31
|
+
import { execFile } from "node:child_process";
|
|
32
|
+
import { promisify } from "node:util";
|
|
33
|
+
const exec = promisify(execFile);
|
|
34
|
+
async function git(dir, args) {
|
|
35
|
+
const { stdout } = await exec("git", args, {
|
|
36
|
+
cwd: dir,
|
|
37
|
+
encoding: "utf8",
|
|
38
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
39
|
+
});
|
|
40
|
+
return stdout;
|
|
41
|
+
}
|
|
42
|
+
/** Cuántos ficheros se nombran antes de cortar: es una pista, no un informe. */
|
|
43
|
+
const MAX_FICHEROS = 5;
|
|
44
|
+
/**
|
|
45
|
+
* El aviso, o cadena vacía. Separado de la parte que habla con git para poder
|
|
46
|
+
* probarlo sin montar repos: mismo patrón que `guardFindings`.
|
|
47
|
+
*/
|
|
48
|
+
export function avisoDeRamaVieja(estado) {
|
|
49
|
+
if (!estado || estado.solapan.length === 0)
|
|
50
|
+
return "";
|
|
51
|
+
const { rama, base, detras, solapan } = estado;
|
|
52
|
+
const muestra = solapan.slice(0, MAX_FICHEROS).join(", ");
|
|
53
|
+
const resto = solapan.length > MAX_FICHEROS
|
|
54
|
+
? ` (+${solapan.length - MAX_FICHEROS} más)`
|
|
55
|
+
: "";
|
|
56
|
+
return (`⚠ ChangeBook — tu rama "${rama}" está ${detras} commit(s) por detrás de ${base}, ` +
|
|
57
|
+
`y ${solapan.length} fichero(s) que tocas ya cambiaron ahí: ${muestra}${resto}.\n` +
|
|
58
|
+
` Al mergear, tu versión —más vieja— gana y REVIERTE esos cambios. ` +
|
|
59
|
+
`Las PRs #358 y #359 se cerraron por esto (667 y 856 líneas).\n` +
|
|
60
|
+
` Arreglo: git fetch && git rebase ${base}`);
|
|
61
|
+
}
|
|
62
|
+
/** La rama por defecto del remoto, o `origin/main` si no se puede saber. */
|
|
63
|
+
async function baseDelRemoto(dir) {
|
|
64
|
+
try {
|
|
65
|
+
const ref = (await git(dir, ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"])).trim();
|
|
66
|
+
// refs/remotes/origin/main -> origin/main
|
|
67
|
+
const m = /^refs\/remotes\/(.+)$/.exec(ref);
|
|
68
|
+
if (m)
|
|
69
|
+
return m[1];
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// Sin origin/HEAD configurado: se prueba el nombre habitual.
|
|
73
|
+
}
|
|
74
|
+
return "origin/main";
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Lee el estado de la rama. `null` cuando no aplica o no se puede saber — y esos
|
|
78
|
+
* casos son la mayoría en repos ajenos, así que fallan en SILENCIO: un guardián
|
|
79
|
+
* que grita en cada commit de quien no usa ramas se desinstala el primer día.
|
|
80
|
+
*/
|
|
81
|
+
export async function estadoDeRama(dir) {
|
|
82
|
+
let rama;
|
|
83
|
+
try {
|
|
84
|
+
rama = (await git(dir, ["rev-parse", "--abbrev-ref", "HEAD"])).trim();
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
if (!rama || rama === "HEAD")
|
|
90
|
+
return null; // detached
|
|
91
|
+
const base = await baseDelRemoto(dir);
|
|
92
|
+
// Estar EN la rama por defecto no es ir por detrás de nadie.
|
|
93
|
+
if (base.endsWith(`/${rama}`))
|
|
94
|
+
return null;
|
|
95
|
+
let mergeBase;
|
|
96
|
+
try {
|
|
97
|
+
mergeBase = (await git(dir, ["merge-base", "HEAD", base])).trim();
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return null; // sin remoto, sin esa rama, o repo recién creado
|
|
101
|
+
}
|
|
102
|
+
if (!mergeBase)
|
|
103
|
+
return null;
|
|
104
|
+
let detras = 0;
|
|
105
|
+
try {
|
|
106
|
+
detras = Number((await git(dir, ["rev-list", "--count", `HEAD..${base}`])).trim());
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
if (!Number.isFinite(detras) || detras <= 0)
|
|
112
|
+
return null;
|
|
113
|
+
// Los dos lados del triángulo, desde el punto de separación.
|
|
114
|
+
const ficheros = async (desde, hasta) => new Set((await git(dir, ["diff", "--name-only", `${desde}..${hasta}`]))
|
|
115
|
+
.split("\n")
|
|
116
|
+
.map((l) => l.trim())
|
|
117
|
+
.filter(Boolean));
|
|
118
|
+
let mios;
|
|
119
|
+
let suyos;
|
|
120
|
+
try {
|
|
121
|
+
mios = await ficheros(mergeBase, "HEAD");
|
|
122
|
+
suyos = await ficheros(mergeBase, base);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
const solapan = [...mios].filter((f) => suyos.has(f)).sort();
|
|
128
|
+
return { rama, base, detras, solapan };
|
|
129
|
+
}
|
|
130
|
+
/** Lo que imprime el guardián. Cadena vacía = nada que decir. */
|
|
131
|
+
export async function avisoDeRamaPara(dir) {
|
|
132
|
+
try {
|
|
133
|
+
return avisoDeRamaVieja(await estadoDeRama(dir));
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return "";
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
//# sourceMappingURL=rama.js.map
|
package/dist/sync.js
CHANGED
|
@@ -285,23 +285,25 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
|
|
|
285
285
|
...taskTitles.map((t) => `- ${sanitizeCell(t.slice(0, 120))}`),
|
|
286
286
|
]
|
|
287
287
|
: [];
|
|
288
|
-
// El orden de renderizado (legibilidad) y la prioridad de presupuesto
|
|
289
|
-
//
|
|
290
|
-
//
|
|
288
|
+
// El orden de renderizado (legibilidad) y la prioridad de presupuesto (qué se
|
|
289
|
+
// recorta primero) son independientes. Los dos importan, y hasta el 2026-07-26
|
|
290
|
+
// solo uno estaba bien.
|
|
291
|
+
//
|
|
292
|
+
// EL RIESGO VA PRIMERO AL LEER, y eso es el cambio. El presupuesto ya
|
|
293
|
+
// priorizaba las regresiones sobre el mapa, pero el ORDEN DE LECTURA ponía
|
|
294
|
+
// `### Módulos` en cabeza, así que lo que estaba frágil quedaba debajo de una
|
|
295
|
+
// lista de quince módulos. Medido ese día con 30 corridas: el bloque empujado
|
|
296
|
+
// al prompt del agente entrega 0,40 de los hechos de riesgo contra 0,60 de la
|
|
297
|
+
// tool, y las cinco corridas del brazo con empuje van de 0,4 a 0,6 mientras las
|
|
298
|
+
// de git puro van de 0 a 0,2. O sea: el canal llega, el agente lo tiene
|
|
299
|
+
// delante, y no lo usa. La hipótesis que queda es la posición.
|
|
300
|
+
//
|
|
301
|
+
// Y `hotspots` sube de prioridad 4 a 1. Con 4 era la penúltima en caer, así que
|
|
302
|
+
// en un proyecto con muchos módulos se recortaba y los críticos solo aparecían
|
|
303
|
+
// como un ⚠ inline dentro de la lista del mapa — el sitio exacto donde no se
|
|
304
|
+
// leen. Los módulos bajan a 3: el mapa se puede pedir con una tool, el riesgo
|
|
305
|
+
// de hoy no está en ningún sitio más.
|
|
291
306
|
const sections = [
|
|
292
|
-
{ key: 'modules', priority: 2, title: '### Módulos', lines: moduleLines },
|
|
293
|
-
{
|
|
294
|
-
key: 'tasks',
|
|
295
|
-
priority: 1,
|
|
296
|
-
title: '### Encargos pendientes del dueño (proponte atacarlos)',
|
|
297
|
-
lines: taskLines,
|
|
298
|
-
},
|
|
299
|
-
{
|
|
300
|
-
key: 'couplings',
|
|
301
|
-
priority: 3,
|
|
302
|
-
title: '### Módulos que cambian juntos (si tocas uno, revisa el otro)',
|
|
303
|
-
lines: couplingLines,
|
|
304
|
-
},
|
|
305
307
|
{
|
|
306
308
|
key: 'alerts',
|
|
307
309
|
priority: 0,
|
|
@@ -316,10 +318,23 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
|
|
|
316
318
|
},
|
|
317
319
|
{
|
|
318
320
|
key: 'hotspots',
|
|
319
|
-
priority:
|
|
320
|
-
title: '###
|
|
321
|
+
priority: 1,
|
|
322
|
+
title: '### Módulos críticos (frágiles por diseño: revisa antes de modificar)',
|
|
321
323
|
lines: hotspotLines,
|
|
322
324
|
},
|
|
325
|
+
{
|
|
326
|
+
key: 'couplings',
|
|
327
|
+
priority: 2,
|
|
328
|
+
title: '### Módulos que cambian juntos (si tocas uno, revisa el otro)',
|
|
329
|
+
lines: couplingLines,
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
key: 'tasks',
|
|
333
|
+
priority: 1,
|
|
334
|
+
title: '### Encargos pendientes del dueño (proponte atacarlos)',
|
|
335
|
+
lines: taskLines,
|
|
336
|
+
},
|
|
337
|
+
{ key: 'modules', priority: 3, title: '### Módulos', lines: moduleLines },
|
|
323
338
|
{
|
|
324
339
|
key: 'changes',
|
|
325
340
|
priority: 5,
|
|
@@ -328,21 +343,62 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
|
|
|
328
343
|
},
|
|
329
344
|
];
|
|
330
345
|
let budget = SYNC_BUDGET_CHARS - head.join('\n').length - END.length;
|
|
346
|
+
// ── El suelo del mapa ──────────────────────────────────────────────────────
|
|
347
|
+
//
|
|
348
|
+
// El presupuesto es de suma cero, así que poner el riesgo primero se lo come.
|
|
349
|
+
// Medido el 2026-07-26 al hacerlo: con datos de producción reales (1 regresión,
|
|
350
|
+
// 1 control en riesgo, 2 módulos críticos, 2 encargos) el mapa cayó de 18
|
|
351
|
+
// módulos a DOS.
|
|
352
|
+
//
|
|
353
|
+
// Y eso no es un detalle estético: el estudio de 76 agentes del 2026-07-20
|
|
354
|
+
// concluyó que el mapa expulsado era «la causa nº 1 de que el agente ignore o
|
|
355
|
+
// desconfíe del atlas». Cambiar un fallo medido por otro fallo medido no es una
|
|
356
|
+
// mejora.
|
|
357
|
+
//
|
|
358
|
+
// Así que el reparto deja de ser el-que-llega-primero-se-lo-lleva-todo: se
|
|
359
|
+
// reserva lo que cuestan las primeras MIN_LINEAS_MAPA líneas del mapa, y solo
|
|
360
|
+
// el resto se disputa por prioridad. Si el mapa tiene menos líneas que el
|
|
361
|
+
// suelo, sobra menos reserva; si no hay mapa, no se reserva nada.
|
|
362
|
+
const MIN_LINEAS_MAPA = 6;
|
|
363
|
+
const mapa = sections.find((x) => x.key === 'modules');
|
|
364
|
+
let reservaMapa = 0;
|
|
365
|
+
if (mapa && mapa.lines.length > 0) {
|
|
366
|
+
reservaMapa = mapa.title.length + 2;
|
|
367
|
+
for (const line of mapa.lines.slice(0, MIN_LINEAS_MAPA)) {
|
|
368
|
+
reservaMapa += line.length + 1;
|
|
369
|
+
}
|
|
370
|
+
// Nunca más de un tercio: el suelo protege al mapa, no lo convierte en el
|
|
371
|
+
// dueño del bloque.
|
|
372
|
+
reservaMapa = Math.min(reservaMapa, Math.floor(budget / 3));
|
|
373
|
+
// Se APARTA del fondo común aquí. Sin esto, dárselo luego al mapa como
|
|
374
|
+
// `budget + reserva` lo contaba dos veces y el bloque se pasaba del tope
|
|
375
|
+
// (medido: 2.344 de 2.000).
|
|
376
|
+
budget -= reservaMapa;
|
|
377
|
+
}
|
|
331
378
|
const includedCount = new Map();
|
|
332
379
|
for (const s of [...sections].sort((a, b) => a.priority - b.priority)) {
|
|
333
380
|
if (s.lines.length === 0)
|
|
334
381
|
continue;
|
|
382
|
+
// El mapa gasta su reserva ADEMÁS de lo que haya quedado libre.
|
|
383
|
+
const disponible = s.key === 'modules' ? budget + reservaMapa : budget;
|
|
335
384
|
let cost = s.title.length + 2; // título + línea en blanco separadora
|
|
336
385
|
let count = 0;
|
|
337
386
|
for (const line of s.lines) {
|
|
338
|
-
if (cost + line.length >
|
|
387
|
+
if (cost + line.length > disponible)
|
|
339
388
|
break;
|
|
340
389
|
cost += line.length + 1;
|
|
341
390
|
count += 1;
|
|
342
391
|
}
|
|
343
392
|
if (count > 0) {
|
|
344
393
|
includedCount.set(s.key, count);
|
|
345
|
-
|
|
394
|
+
if (s.key === 'modules') {
|
|
395
|
+
// Lo que consumió por encima de su reserva sale del fondo común.
|
|
396
|
+
budget -= Math.max(0, cost - reservaMapa);
|
|
397
|
+
reservaMapa = 0;
|
|
398
|
+
}
|
|
399
|
+
else {
|
|
400
|
+
budget -= cost;
|
|
401
|
+
}
|
|
346
402
|
}
|
|
347
403
|
}
|
|
348
404
|
const lines = [...head];
|
|
@@ -371,6 +427,63 @@ const PAIR_SEP = '\u0000';
|
|
|
371
427
|
// (supabase/functions/mcp/scope.ts): si las dos implementaciones derivan, el
|
|
372
428
|
// mismo repo enseñaría acoplamientos distintos según por dónde entre el
|
|
373
429
|
// agente — y la deriva sería silenciosa.
|
|
430
|
+
/**
|
|
431
|
+
* Un fichero de pruebas, por convencion de ruta o de nombre.
|
|
432
|
+
*
|
|
433
|
+
* Se mira la RUTA y no el `domain` del modulo a proposito: el dominio es prosa
|
|
434
|
+
* que inventa el modelo, y medido en prod el 2026-07-27 llega con variantes
|
|
435
|
+
* ("Calidad y pruebas" 215 veces, pero tambien "Testing" 2, "Interfaz" e
|
|
436
|
+
* "Interfaz de Usuario" por separado). En un proyecto en ingles no funcionaria
|
|
437
|
+
* nunca. La ruta es un hecho.
|
|
438
|
+
*/
|
|
439
|
+
const ES_FICHERO_DE_PRUEBAS = /(^|\/)(tests?|__tests__|spec)\/|\.(test|spec)\.[A-Za-z0-9]+$/i;
|
|
440
|
+
/**
|
|
441
|
+
* Cuando un modulo es "casi todo tests", su co-cambio no informa de nada.
|
|
442
|
+
*
|
|
443
|
+
* POR QUE 0,8: medido sobre los 65 modulos de AppAtlas el 2026-07-27, el reparto
|
|
444
|
+
* tiene un hueco limpio y el umbral cae dentro — «Pruebas automatizadas» 93%,
|
|
445
|
+
* «Verificación manual y de consola» 81%, y el siguiente ya baja a 56%. Y esos
|
|
446
|
+
* DOS son exactamente los que producian las parejas ruidosas: «Pruebas
|
|
447
|
+
* automatizadas ↔ Radio de impacto» era, por debajo, `impact.ts ↔
|
|
448
|
+
* hookDeImpacto.test.ts` — un fichero y su propio test servido como
|
|
449
|
+
* acoplamiento de producto, 1 de las 5 parejas.
|
|
450
|
+
*
|
|
451
|
+
* Ningun modulo llega al 100%, asi que exigir 100% no filtraria nada. Y dos
|
|
452
|
+
* reglas que se probaron y se cayeron, para que nadie las reintente: por
|
|
453
|
+
* `domain` (prosa libre) y por PROMISCUIDAD — suena bien y NO discrimina:
|
|
454
|
+
* «Pruebas automatizadas» tiene 28 socios distintos, MENOS que «Análisis de
|
|
455
|
+
* cambios» (39).
|
|
456
|
+
*
|
|
457
|
+
* ESPEJO EXACTO de supabase/functions/mcp/scope.ts. Paridad en
|
|
458
|
+
* test/paridadCoCambios.test.ts.
|
|
459
|
+
*/
|
|
460
|
+
const RATIO_MODULO_DE_PRUEBAS = 0.8;
|
|
461
|
+
export function modulosDePruebas(rows) {
|
|
462
|
+
const porModulo = new Map();
|
|
463
|
+
for (const r of rows) {
|
|
464
|
+
const label = (r.module ?? '').trim();
|
|
465
|
+
if (!label || !Array.isArray(r.files))
|
|
466
|
+
continue;
|
|
467
|
+
const set = porModulo.get(label) ?? new Set();
|
|
468
|
+
for (const f of r.files) {
|
|
469
|
+
if (typeof f === 'string' && f.trim())
|
|
470
|
+
set.add(f.trim());
|
|
471
|
+
}
|
|
472
|
+
porModulo.set(label, set);
|
|
473
|
+
}
|
|
474
|
+
const fuera = new Set();
|
|
475
|
+
for (const [label, ficheros] of porModulo) {
|
|
476
|
+
if (ficheros.size === 0)
|
|
477
|
+
continue;
|
|
478
|
+
let pruebas = 0;
|
|
479
|
+
for (const f of ficheros)
|
|
480
|
+
if (ES_FICHERO_DE_PRUEBAS.test(f))
|
|
481
|
+
pruebas += 1;
|
|
482
|
+
if (pruebas / ficheros.size >= RATIO_MODULO_DE_PRUEBAS)
|
|
483
|
+
fuera.add(label);
|
|
484
|
+
}
|
|
485
|
+
return fuera;
|
|
486
|
+
}
|
|
374
487
|
export function coChangePairs(rows) {
|
|
375
488
|
const byAnalysis = new Map();
|
|
376
489
|
for (const r of rows) {
|
|
@@ -397,11 +510,16 @@ export function coChangePairs(rows) {
|
|
|
397
510
|
}
|
|
398
511
|
}
|
|
399
512
|
}
|
|
513
|
+
// Los modulos de pruebas cambian con lo que sea, por construccion: su pareja
|
|
514
|
+
// no es un acoplamiento, es la definicion de tener tests.
|
|
515
|
+
const dePruebas = modulosDePruebas(rows);
|
|
400
516
|
const pairs = [];
|
|
401
517
|
for (const [key, count] of together) {
|
|
402
518
|
if (count < MIN_PAIR_COUNT)
|
|
403
519
|
continue;
|
|
404
520
|
const [a, b] = key.split(PAIR_SEP);
|
|
521
|
+
if (dePruebas.has(a) || dePruebas.has(b))
|
|
522
|
+
continue;
|
|
405
523
|
const rate = count / Math.min(appear.get(a) ?? 1, appear.get(b) ?? 1);
|
|
406
524
|
if (rate >= MIN_PAIR_RATE)
|
|
407
525
|
pairs.push({ a, b, rate });
|
package/dist/tools.js
CHANGED
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
import { z } from "zod";
|
|
9
9
|
import { execFileAsync } from "./git.js";
|
|
10
|
-
import { avisoRefutado, contarEnRepo, slugifyProject, } from "./guard.js";
|
|
10
|
+
import { avisoRefutado, contarEnRepo, dondeApareceElSimbolo, dondeComprobarlo, ficherosEnRepo, slugifyProject, unoPorTexto, } from "./guard.js";
|
|
11
11
|
import { SupabaseError } from "./supabase.js";
|
|
12
|
+
import { coChangePairs } from "./sync.js";
|
|
12
13
|
const CHARACTER_LIMIT = 25_000;
|
|
13
14
|
/**
|
|
14
15
|
* El contrato temporal de las respuestas del atlas (benchmark 2026-07-20: el
|
|
@@ -778,9 +779,14 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
|
|
|
778
779
|
for (const f of perFile) {
|
|
779
780
|
commitsByFile.set(f.file, recentCommitsForFile(f.changelogIds, commitById));
|
|
780
781
|
}
|
|
781
|
-
const [alerts, watched, recidivismRows, depsRows] = await Promise.all([
|
|
782
|
+
const [alerts, watched, recidivismRows, depsRows, coChangeRows] = await Promise.all([
|
|
782
783
|
moduleNames.length
|
|
783
|
-
? db.rest(
|
|
784
|
+
? db.rest(
|
|
785
|
+
// Con `evidence_scope`: sin él ni se refuta (alcanceDelRepo dice
|
|
786
|
+
// "sin_declarar" siempre) ni se sirve la línea NOT CHECKABLE
|
|
787
|
+
// HERE, que es la única forma de que un aviso sobre producción
|
|
788
|
+
// no se compruebe con un grep del repo. Ver guard.ts.
|
|
789
|
+
`regression_alerts?select=module,plain,evidence_symbol,evidence_expect,evidence_scope,evidence_line&resolved_at=is.null&module=in.(${encodeURIComponent(quotedInList(moduleNames))})&order=created_at.desc&limit=10` +
|
|
784
790
|
pf)
|
|
785
791
|
: Promise.resolve([]),
|
|
786
792
|
// Constantes vigiladas (espejo del hospedado): el valor VIGENTE con
|
|
@@ -810,6 +816,23 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
|
|
|
810
816
|
.rest(`change_module?select=module,deps&deps=not.is.null&order=created_at.desc&limit=${MODULE_GRAPH_WINDOW_ROWS}` +
|
|
811
817
|
pf)
|
|
812
818
|
.catch(() => []),
|
|
819
|
+
// Co-cambio: qué se toca JUNTO en la práctica. Es la otra mitad del
|
|
820
|
+
// radio de impacto — `deps` es la arista DECLARADA (quién llama a
|
|
821
|
+
// quién) y esto es la HISTÓRICA (qué acabó cambiando a la vez), que
|
|
822
|
+
// solo sale del historial y es la señal que menos gente tiene.
|
|
823
|
+
//
|
|
824
|
+
// Es una CUARTA consulta y no se disimula. Lo que la hace aceptable es
|
|
825
|
+
// que entra en el MISMO Promise.all: en paralelo, la latencia de la
|
|
826
|
+
// tool es la de su consulta más lenta, no la suma — así que el p50
|
|
827
|
+
// solo sube si esta resulta ser la más lenta de las cuatro. Cuesta
|
|
828
|
+
// carga de servidor, no espera del agente.
|
|
829
|
+
//
|
|
830
|
+
// No se puede reutilizar la del grafo: aquella filtra `deps=not.is
|
|
831
|
+
// .null` y el co-cambio necesita TODAS las filas de la ventana.
|
|
832
|
+
db
|
|
833
|
+
.rest(`change_module?select=changelog_id,module,files&order=created_at.desc&limit=${MODULE_GRAPH_WINDOW_ROWS}` +
|
|
834
|
+
pf)
|
|
835
|
+
.catch(() => []),
|
|
813
836
|
]);
|
|
814
837
|
const watchedByFile = new Map();
|
|
815
838
|
for (const w of watched) {
|
|
@@ -822,23 +845,67 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
|
|
|
822
845
|
// evidencia, sin repo o con la búsqueda rota, la alerta pasa; y si el
|
|
823
846
|
// cwd no es el proyecto consultado, no se refuta nada (ver
|
|
824
847
|
// cwdEsElProyecto).
|
|
825
|
-
const
|
|
848
|
+
const enElProyecto = cwdEsElProyecto(project);
|
|
849
|
+
const refutada = enElProyecto
|
|
826
850
|
? (a) => avisoRefutado(a, (s) => contarEnRepo(process.cwd(), s))
|
|
827
851
|
: () => false;
|
|
828
|
-
|
|
852
|
+
// La otra mitad, desde el 2026-07-26: un aviso 'absent' cuyo símbolo
|
|
853
|
+
// SIGUE apareciendo ya no se descarta (era ambiguo y tiraba las dos
|
|
854
|
+
// alertas que predijeron el desfase de esquema de producción). Se sirve
|
|
855
|
+
// diciendo dónde aparece, que es contestar el condicional del texto
|
|
856
|
+
// ("...si los hubiera") en vez de prohibir la redacción.
|
|
857
|
+
//
|
|
858
|
+
// Mismas palabras que el hook (impactText) a propósito: dos redacciones
|
|
859
|
+
// del mismo hecho son dos hechos para el agente.
|
|
860
|
+
const localizada = enElProyecto
|
|
861
|
+
? (a) => dondeApareceElSimbolo(a, (s) => ficherosEnRepo(process.cwd(), s))
|
|
862
|
+
: () => null;
|
|
863
|
+
// Se construye la lista ANTES de renderizar para poder dejar una linea
|
|
864
|
+
// por texto: dos avisos con la misma frase se leen como una repeticion
|
|
865
|
+
// aunque por dentro sean afirmaciones opuestas, y sobre dos frases
|
|
866
|
+
// identicas no se puede actuar distinto. Gana el que trae comprobacion.
|
|
867
|
+
const porModulo = new Map();
|
|
829
868
|
for (const a of alerts) {
|
|
830
869
|
const m = (a.module ?? "").trim();
|
|
831
870
|
if (!m || !a.plain)
|
|
832
871
|
continue;
|
|
833
872
|
if (refutada(a))
|
|
834
873
|
continue;
|
|
835
|
-
|
|
874
|
+
const donde = localizada(a);
|
|
875
|
+
const fuera = dondeComprobarlo(a);
|
|
876
|
+
let extra = "";
|
|
877
|
+
if (donde) {
|
|
878
|
+
extra +=
|
|
879
|
+
`\n → CHECKED NOW: "${(a.evidence_symbol ?? "").trim()}" still appears in ${donde.join(", ")}` +
|
|
880
|
+
` — alert expects it gone: either a reference was missed, or the alert is stale`;
|
|
881
|
+
}
|
|
882
|
+
// El repo no puede contestar esta afirmacion: se dice, en vez de
|
|
883
|
+
// dejar que se compruebe donde no era.
|
|
884
|
+
if (fuera)
|
|
885
|
+
extra += `\n → NOT CHECKABLE HERE: ${fuera}`;
|
|
886
|
+
// La linea que lo provoco. Mismas palabras que el hook (impactText).
|
|
887
|
+
if (a.evidence_line) {
|
|
888
|
+
extra += `\n → TRIGGERED BY: ${String(a.evidence_line).trim()}`;
|
|
889
|
+
}
|
|
890
|
+
porModulo.set(m, [...(porModulo.get(m) ?? []), { plain: a.plain, extra }]);
|
|
891
|
+
}
|
|
892
|
+
const alertsByModule = new Map();
|
|
893
|
+
for (const [m, entradas] of porModulo) {
|
|
894
|
+
alertsByModule.set(m, unoPorTexto(entradas, (e) => e.plain, (e) => e.extra.length > 0).map((e) => e.plain + e.extra));
|
|
836
895
|
}
|
|
837
896
|
// Reincidencia: nº de problemas de regresión DISTINTOS por módulo
|
|
838
897
|
// (count(distinct plain), all-time), solo los con antecedentes (>= 2).
|
|
839
898
|
// Fuente única compartida (computeRecidivism) — antes 3 copias.
|
|
840
899
|
const recidivismByModule = computeRecidivism(recidivismRows);
|
|
841
900
|
const dependentsByModule = dependentsOf(moduleNames, depsRows);
|
|
901
|
+
// MISMA función que el bloque de CLAUDE.md y que el hook: una sola
|
|
902
|
+
// respuesta a "¿con qué cambia junto esto?". Sus umbrales viven en ella
|
|
903
|
+
// (3 apariciones, 60%, tope 5) y excluye los módulos de pruebas, que
|
|
904
|
+
// cambian con todo por construcción.
|
|
905
|
+
const parejas = coChangePairs(coChangeRows);
|
|
906
|
+
const coCambiosDe = (modulo) => parejas
|
|
907
|
+
.filter((p) => p.a === modulo || p.b === modulo)
|
|
908
|
+
.map((p) => ({ module: p.a === modulo ? p.b : p.a, rate: p.rate }));
|
|
842
909
|
// Ancla temporal: el último commit analizado del proyecto vs el HEAD
|
|
843
910
|
// de este árbol (misma puerta de proyecto que la refutación).
|
|
844
911
|
// Best-effort: el ancla jamás rompe la lectura que ancla.
|
|
@@ -885,6 +952,12 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
|
|
|
885
952
|
if (dependents?.length) {
|
|
886
953
|
lines.push(` - ↘ DEPENDS ON THIS: ${dependents.join(", ")} — check these too before you finish`);
|
|
887
954
|
}
|
|
955
|
+
// PALABRA POR PALABRA como el hook (impactText). Dos redacciones del
|
|
956
|
+
// mismo hecho son dos hechos para el agente, y aquí importa el doble
|
|
957
|
+
// porque el mismo agente ve las dos superficies en la misma sesión.
|
|
958
|
+
for (const cc of coCambiosDe(m.module)) {
|
|
959
|
+
lines.push(` - ↔ CHANGES WITH: ${cc.module} (together in ${Math.round(cc.rate * 100)}% of its changes) — check it before you finish`);
|
|
960
|
+
}
|
|
888
961
|
}
|
|
889
962
|
for (const w of watchedByFile.get(f.file) ?? []) {
|
|
890
963
|
lines.push(`- Current value: ${w.name} = ${w.value}` +
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "changebook",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"mcpName": "io.github.raulbr90/changebook",
|
|
5
5
|
"description": "ChangeBook for coding agents: MCP server (product memory for Claude Code/Codex) + CLI to sign in, analyze changes and sync the product map.",
|
|
6
6
|
"type": "module",
|
package/server.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
3
|
"name": "io.github.raulbr90/changebook",
|
|
4
4
|
"description": "Query your product's living memory: module map + analyzed change history. Read-only MCP tools.",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.5.0",
|
|
6
6
|
"websiteUrl": "https://changebook.dev",
|
|
7
7
|
"remotes": [
|
|
8
8
|
{
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"registryType": "npm",
|
|
16
16
|
"registryBaseUrl": "https://registry.npmjs.org",
|
|
17
17
|
"identifier": "changebook",
|
|
18
|
-
"version": "0.
|
|
18
|
+
"version": "0.5.0",
|
|
19
19
|
"transport": {
|
|
20
20
|
"type": "stdio"
|
|
21
21
|
}
|