changebook 0.4.10 → 0.6.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/guard.js +152 -8
- package/dist/impact.js +511 -28
- package/dist/rama.js +139 -0
- package/dist/sync.js +183 -9
- package/dist/tools.js +43 -72
- package/package.json +1 -1
- package/server.json +2 -2
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
|
@@ -97,7 +97,7 @@ export async function fetchBriefSection(db, targetDir) {
|
|
|
97
97
|
}
|
|
98
98
|
const projectId = /project_id=eq\.([0-9a-f-]+)/.exec(projectFilter)?.[1] ?? null;
|
|
99
99
|
const since = new Date(Date.now() - ALERT_WINDOW_DAYS * 24 * 3600 * 1000).toISOString();
|
|
100
|
-
const [moduleRows, changes, alerts, pendingTasks, healthRows] = await Promise.all([
|
|
100
|
+
const [moduleRows, changes, alerts, historial, pendingTasks, healthRows] = await Promise.all([
|
|
101
101
|
db.rest('change_module?select=changelog_id,module,domain,risk,files,note,created_at&order=created_at.desc&limit=500' +
|
|
102
102
|
projectFilter),
|
|
103
103
|
db.rest(`changelog?select=business_impact,created_at&order=created_at.desc&limit=${MAX_CHANGES}` +
|
|
@@ -106,6 +106,12 @@ export async function fetchBriefSection(db, targetDir) {
|
|
|
106
106
|
.rest(`regression_alerts?select=module,plain,created_at&created_at=gte.${since}&resolved_at=is.null&order=created_at.desc&limit=${MAX_ALERTS}` +
|
|
107
107
|
projectFilter)
|
|
108
108
|
.catch(() => []),
|
|
109
|
+
// HISTORIA de regresiones: todas, sin ventana y sin filtrar por abiertas.
|
|
110
|
+
// Es lo que sustituye al mapa de modulos en el bloque — ver `hechosCaros`.
|
|
111
|
+
db
|
|
112
|
+
.rest('regression_alerts?select=module,plain,created_at,changelog_id&order=created_at.desc&limit=500' +
|
|
113
|
+
projectFilter)
|
|
114
|
+
.catch(() => []),
|
|
109
115
|
projectResolved && projectId
|
|
110
116
|
? db
|
|
111
117
|
.callRpc('list_agent_tasks', {
|
|
@@ -121,7 +127,27 @@ export async function fetchBriefSection(db, targetDir) {
|
|
|
121
127
|
.catch(() => []),
|
|
122
128
|
]);
|
|
123
129
|
const health = summarizeHealth(healthRows);
|
|
124
|
-
|
|
130
|
+
// El commit de cada regresion, para poder citarlo. Una consulta mas, y solo
|
|
131
|
+
// por los analisis que de verdad rompieron algo: es la diferencia entre «este
|
|
132
|
+
// modulo ha roto 3 veces» y «este modulo ha roto 3 veces, aqui, aqui y aqui».
|
|
133
|
+
// Sin el hash, la frase es una etiqueta; con el, es algo que se puede ir a
|
|
134
|
+
// mirar. El sync corre UNA vez por sesion, asi que la ronda extra se paga una
|
|
135
|
+
// vez y no por edicion.
|
|
136
|
+
const idsDeRegresion = [
|
|
137
|
+
...new Set(historial.map((h) => h.changelog_id).filter((x) => Boolean(x))),
|
|
138
|
+
].slice(0, 60);
|
|
139
|
+
const commitPorAnalisis = new Map();
|
|
140
|
+
if (idsDeRegresion.length > 0) {
|
|
141
|
+
const filas = await db
|
|
142
|
+
.rest(`changelog?select=id,commit_hash&id=in.(${idsDeRegresion.join(',')})` +
|
|
143
|
+
projectFilter)
|
|
144
|
+
.catch(() => []);
|
|
145
|
+
for (const f of filas) {
|
|
146
|
+
if (f.commit_hash)
|
|
147
|
+
commitPorAnalisis.set(f.id, f.commit_hash.slice(0, 7));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const section = buildSection(moduleRows, changes, alerts, projectName, pendingTasks, health.at_risk, historial, commitPorAnalisis);
|
|
125
151
|
return { section, projectId, projectResolved };
|
|
126
152
|
}
|
|
127
153
|
export async function syncContextFiles(db, targetDir, opts = {}) {
|
|
@@ -146,7 +172,67 @@ export async function syncContextFiles(db, targetDir, opts = {}) {
|
|
|
146
172
|
}
|
|
147
173
|
}
|
|
148
174
|
/** Exported for tests. */
|
|
149
|
-
|
|
175
|
+
/**
|
|
176
|
+
* LO QUE YA COSTO CARO AQUI: modulos que han roto algo, con fecha y commit.
|
|
177
|
+
*
|
|
178
|
+
* ── POR QUE ESTO Y NO EL MAPA DE MODULOS ────────────────────────────────────
|
|
179
|
+
*
|
|
180
|
+
* La documentacion de Claude Code lo dice de su propio `/doctor`: recorta del
|
|
181
|
+
* CLAUDE.md «directory layouts, dependency lists, and architecture overviews»
|
|
182
|
+
* y conserva «pitfalls, rationale, and conventions that differ from tool
|
|
183
|
+
* defaults». La lista de modulos con su dominio es DERIVABLE: el agente la saca
|
|
184
|
+
* leyendo el repo, y la herramienta la recorta por su cuenta.
|
|
185
|
+
*
|
|
186
|
+
* Esto no. Que un modulo haya roto tres veces sale del HISTORIAL, no del
|
|
187
|
+
* codigo: ni un grep ni un glob pueden encontrarlo, y es exactamente la
|
|
188
|
+
* categoria que /doctor conserva.
|
|
189
|
+
*
|
|
190
|
+
* ── DOS DECISIONES QUE PARECEN DETALLES ─────────────────────────────────────
|
|
191
|
+
*
|
|
192
|
+
* FECHA Y HASH POR ENTRADA, NUNCA EN LA CABECERA. Una fecha en la cabecera
|
|
193
|
+
* cambiaria a diario e invalidaria el prefijo de prompt-cache de TODO el
|
|
194
|
+
* fichero en cada sesion. Por entrada no: una linea que no cambia no invalida
|
|
195
|
+
* nada, y `upsertSection` sigue devolviendo `unchanged` cuando no ha pasado
|
|
196
|
+
* nada, asi que hechos con fecha no producen churn diario en `git status`.
|
|
197
|
+
*
|
|
198
|
+
* SE CUENTAN PROBLEMAS DISTINTOS, no filas. Mismo criterio que
|
|
199
|
+
* `computeRecidivism`: la misma regresion re-levantada tres veces es UNA, y
|
|
200
|
+
* contarla tres veces convertiria el churn del generador en falsa gravedad.
|
|
201
|
+
*/
|
|
202
|
+
export function hechosCaros(historial, commitPorAnalisis, tope = 6) {
|
|
203
|
+
const porModulo = new Map();
|
|
204
|
+
for (const h of historial) {
|
|
205
|
+
const modulo = (h.module ?? '').trim();
|
|
206
|
+
const texto = (h.plain ?? '').trim();
|
|
207
|
+
if (!modulo || !texto)
|
|
208
|
+
continue;
|
|
209
|
+
const entrada = porModulo.get(modulo) ?? { textos: new Set(), citas: [] };
|
|
210
|
+
if (!entrada.textos.has(texto)) {
|
|
211
|
+
entrada.textos.add(texto);
|
|
212
|
+
entrada.citas.push({
|
|
213
|
+
fecha: h.created_at.slice(5, 10).split('-').reverse().join('/'),
|
|
214
|
+
commit: h.changelog_id
|
|
215
|
+
? (commitPorAnalisis.get(h.changelog_id) ?? null)
|
|
216
|
+
: null,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
porModulo.set(modulo, entrada);
|
|
220
|
+
}
|
|
221
|
+
return [...porModulo.entries()]
|
|
222
|
+
.sort((a, b) => b[1].textos.size - a[1].textos.size || a[0].localeCompare(b[0]))
|
|
223
|
+
.slice(0, tope)
|
|
224
|
+
.map(([modulo, { textos, citas }]) => {
|
|
225
|
+
const n = textos.size;
|
|
226
|
+
// Tres citas como mucho: la linea es una pista para ir a mirar, no un
|
|
227
|
+
// informe. Con mas, la seccion se come el presupuesto del bloque.
|
|
228
|
+
const cuando = citas
|
|
229
|
+
.slice(0, 3)
|
|
230
|
+
.map((c) => (c.commit ? `${c.fecha} \`${c.commit}\`` : c.fecha))
|
|
231
|
+
.join(', ');
|
|
232
|
+
return `- **${modulo}**: ${n} regresi${n === 1 ? 'ón' : 'ones'}${cuando ? ` (${cuando})` : ''}`;
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
export function buildSection(rows, changes, alerts = [], projectName, pendingTasks = [], atRiskHealth = [], historial = [], commitPorAnalisis = new Map()) {
|
|
150
236
|
// Newest-first rows: the first occurrence of a module is its latest state.
|
|
151
237
|
const seen = new Map();
|
|
152
238
|
for (const row of rows) {
|
|
@@ -210,7 +296,7 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
|
|
|
210
296
|
// disparador abstracto: hay que nombrar sus verbos.
|
|
211
297
|
'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).',
|
|
212
298
|
'',
|
|
213
|
-
'Tools diferidas, en UNA llamada: ToolSearch `select:mcp__changebook__atlas_project_brief,mcp__changebook__atlas_file_context
|
|
299
|
+
'Tools diferidas, en UNA llamada: ToolSearch `select:mcp__changebook__atlas_project_brief,mcp__changebook__atlas_file_context`.',
|
|
214
300
|
'',
|
|
215
301
|
// Proyecto + conducta en UNA línea: cada char de cabecera expulsa mapa.
|
|
216
302
|
//
|
|
@@ -334,7 +420,18 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
|
|
|
334
420
|
title: '### Encargos pendientes del dueño (proponte atacarlos)',
|
|
335
421
|
lines: taskLines,
|
|
336
422
|
},
|
|
337
|
-
{
|
|
423
|
+
{
|
|
424
|
+
key: 'costoso',
|
|
425
|
+
priority: 1,
|
|
426
|
+
title: '### Lo que ya costó caro aquí (revisa antes de tocarlo)',
|
|
427
|
+
lines: hechosCaros(historial, commitPorAnalisis),
|
|
428
|
+
},
|
|
429
|
+
// El mapa baja de 3 a 4, y no es una degradación caprichosa: `/doctor` de
|
|
430
|
+
// Claude Code recorta por su cuenta «architecture overviews» de un
|
|
431
|
+
// CLAUDE.md porque el agente los deriva del repo. Lo que no puede derivar
|
|
432
|
+
// es qué ha roto antes. Se conserva la sección —quitarla del todo cambia
|
|
433
|
+
// más de lo necesario— pero deja de competir con los hechos.
|
|
434
|
+
{ key: 'modules', priority: 4, title: '### Módulos', lines: moduleLines },
|
|
338
435
|
{
|
|
339
436
|
key: 'changes',
|
|
340
437
|
priority: 5,
|
|
@@ -359,8 +456,17 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
|
|
|
359
456
|
// reserva lo que cuestan las primeras MIN_LINEAS_MAPA líneas del mapa, y solo
|
|
360
457
|
// el resto se disputa por prioridad. Si el mapa tiene menos líneas que el
|
|
361
458
|
// suelo, sobra menos reserva; si no hay mapa, no se reserva nada.
|
|
362
|
-
|
|
363
|
-
|
|
459
|
+
// EL SUELO CAMBIA DE DUEÑO. Lo tenía el mapa porque el estudio de 76 agentes
|
|
460
|
+
// del 2026-07-20 concluyó que un mapa expulsado era «la causa nº 1 de que el
|
|
461
|
+
// agente ignore o desconfíe del atlas». Sigue siendo cierto, pero el mapa ya
|
|
462
|
+
// no es lo irreemplazable: el agente lo deriva leyendo el repo, y la propia
|
|
463
|
+
// herramienta lo recorta. Lo que nadie puede derivar es qué rompió antes, así
|
|
464
|
+
// que el suelo protege ahora eso.
|
|
465
|
+
//
|
|
466
|
+
// Y baja de 6 líneas a 3: son entradas mucho más densas —un módulo, un
|
|
467
|
+
// número y hasta tres commits— así que tres ya dicen dónde pisar con cuidado.
|
|
468
|
+
const MIN_LINEAS_MAPA = 3;
|
|
469
|
+
const mapa = sections.find((x) => x.key === 'costoso');
|
|
364
470
|
let reservaMapa = 0;
|
|
365
471
|
if (mapa && mapa.lines.length > 0) {
|
|
366
472
|
reservaMapa = mapa.title.length + 2;
|
|
@@ -380,7 +486,7 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
|
|
|
380
486
|
if (s.lines.length === 0)
|
|
381
487
|
continue;
|
|
382
488
|
// El mapa gasta su reserva ADEMÁS de lo que haya quedado libre.
|
|
383
|
-
const disponible = s.key === '
|
|
489
|
+
const disponible = s.key === 'costoso' ? budget + reservaMapa : budget;
|
|
384
490
|
let cost = s.title.length + 2; // título + línea en blanco separadora
|
|
385
491
|
let count = 0;
|
|
386
492
|
for (const line of s.lines) {
|
|
@@ -391,8 +497,14 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
|
|
|
391
497
|
}
|
|
392
498
|
if (count > 0) {
|
|
393
499
|
includedCount.set(s.key, count);
|
|
394
|
-
if (s.key === '
|
|
500
|
+
if (s.key === 'costoso') {
|
|
395
501
|
// Lo que consumió por encima de su reserva sale del fondo común.
|
|
502
|
+
//
|
|
503
|
+
// La clave tiene que ser la MISMA que la de arriba: al mover el suelo
|
|
504
|
+
// del mapa a los hechos se me quedó aquí `modules`, así que la sección
|
|
505
|
+
// nueva gastaba la reserva Y descontaba su coste entero del fondo —
|
|
506
|
+
// doble gasto— y el mapa se llevaba luego una reserva ya usada. El
|
|
507
|
+
// bloque no reventaba el techo por casualidad, no por diseño.
|
|
396
508
|
budget -= Math.max(0, cost - reservaMapa);
|
|
397
509
|
reservaMapa = 0;
|
|
398
510
|
}
|
|
@@ -427,6 +539,63 @@ const PAIR_SEP = '\u0000';
|
|
|
427
539
|
// (supabase/functions/mcp/scope.ts): si las dos implementaciones derivan, el
|
|
428
540
|
// mismo repo enseñaría acoplamientos distintos según por dónde entre el
|
|
429
541
|
// agente — y la deriva sería silenciosa.
|
|
542
|
+
/**
|
|
543
|
+
* Un fichero de pruebas, por convencion de ruta o de nombre.
|
|
544
|
+
*
|
|
545
|
+
* Se mira la RUTA y no el `domain` del modulo a proposito: el dominio es prosa
|
|
546
|
+
* que inventa el modelo, y medido en prod el 2026-07-27 llega con variantes
|
|
547
|
+
* ("Calidad y pruebas" 215 veces, pero tambien "Testing" 2, "Interfaz" e
|
|
548
|
+
* "Interfaz de Usuario" por separado). En un proyecto en ingles no funcionaria
|
|
549
|
+
* nunca. La ruta es un hecho.
|
|
550
|
+
*/
|
|
551
|
+
const ES_FICHERO_DE_PRUEBAS = /(^|\/)(tests?|__tests__|spec)\/|\.(test|spec)\.[A-Za-z0-9]+$/i;
|
|
552
|
+
/**
|
|
553
|
+
* Cuando un modulo es "casi todo tests", su co-cambio no informa de nada.
|
|
554
|
+
*
|
|
555
|
+
* POR QUE 0,8: medido sobre los 65 modulos de AppAtlas el 2026-07-27, el reparto
|
|
556
|
+
* tiene un hueco limpio y el umbral cae dentro — «Pruebas automatizadas» 93%,
|
|
557
|
+
* «Verificación manual y de consola» 81%, y el siguiente ya baja a 56%. Y esos
|
|
558
|
+
* DOS son exactamente los que producian las parejas ruidosas: «Pruebas
|
|
559
|
+
* automatizadas ↔ Radio de impacto» era, por debajo, `impact.ts ↔
|
|
560
|
+
* hookDeImpacto.test.ts` — un fichero y su propio test servido como
|
|
561
|
+
* acoplamiento de producto, 1 de las 5 parejas.
|
|
562
|
+
*
|
|
563
|
+
* Ningun modulo llega al 100%, asi que exigir 100% no filtraria nada. Y dos
|
|
564
|
+
* reglas que se probaron y se cayeron, para que nadie las reintente: por
|
|
565
|
+
* `domain` (prosa libre) y por PROMISCUIDAD — suena bien y NO discrimina:
|
|
566
|
+
* «Pruebas automatizadas» tiene 28 socios distintos, MENOS que «Análisis de
|
|
567
|
+
* cambios» (39).
|
|
568
|
+
*
|
|
569
|
+
* ESPEJO EXACTO de supabase/functions/mcp/scope.ts. Paridad en
|
|
570
|
+
* test/paridadCoCambios.test.ts.
|
|
571
|
+
*/
|
|
572
|
+
const RATIO_MODULO_DE_PRUEBAS = 0.8;
|
|
573
|
+
export function modulosDePruebas(rows) {
|
|
574
|
+
const porModulo = new Map();
|
|
575
|
+
for (const r of rows) {
|
|
576
|
+
const label = (r.module ?? '').trim();
|
|
577
|
+
if (!label || !Array.isArray(r.files))
|
|
578
|
+
continue;
|
|
579
|
+
const set = porModulo.get(label) ?? new Set();
|
|
580
|
+
for (const f of r.files) {
|
|
581
|
+
if (typeof f === 'string' && f.trim())
|
|
582
|
+
set.add(f.trim());
|
|
583
|
+
}
|
|
584
|
+
porModulo.set(label, set);
|
|
585
|
+
}
|
|
586
|
+
const fuera = new Set();
|
|
587
|
+
for (const [label, ficheros] of porModulo) {
|
|
588
|
+
if (ficheros.size === 0)
|
|
589
|
+
continue;
|
|
590
|
+
let pruebas = 0;
|
|
591
|
+
for (const f of ficheros)
|
|
592
|
+
if (ES_FICHERO_DE_PRUEBAS.test(f))
|
|
593
|
+
pruebas += 1;
|
|
594
|
+
if (pruebas / ficheros.size >= RATIO_MODULO_DE_PRUEBAS)
|
|
595
|
+
fuera.add(label);
|
|
596
|
+
}
|
|
597
|
+
return fuera;
|
|
598
|
+
}
|
|
430
599
|
export function coChangePairs(rows) {
|
|
431
600
|
const byAnalysis = new Map();
|
|
432
601
|
for (const r of rows) {
|
|
@@ -453,11 +622,16 @@ export function coChangePairs(rows) {
|
|
|
453
622
|
}
|
|
454
623
|
}
|
|
455
624
|
}
|
|
625
|
+
// Los modulos de pruebas cambian con lo que sea, por construccion: su pareja
|
|
626
|
+
// no es un acoplamiento, es la definicion de tener tests.
|
|
627
|
+
const dePruebas = modulosDePruebas(rows);
|
|
456
628
|
const pairs = [];
|
|
457
629
|
for (const [key, count] of together) {
|
|
458
630
|
if (count < MIN_PAIR_COUNT)
|
|
459
631
|
continue;
|
|
460
632
|
const [a, b] = key.split(PAIR_SEP);
|
|
633
|
+
if (dePruebas.has(a) || dePruebas.has(b))
|
|
634
|
+
continue;
|
|
461
635
|
const rate = count / Math.min(appear.get(a) ?? 1, appear.get(b) ?? 1);
|
|
462
636
|
if (rate >= MIN_PAIR_RATE)
|
|
463
637
|
pairs.push({ a, b, rate });
|
package/dist/tools.js
CHANGED
|
@@ -9,6 +9,7 @@ import { z } from "zod";
|
|
|
9
9
|
import { execFileAsync } from "./git.js";
|
|
10
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
|
|
@@ -280,76 +281,6 @@ export async function derivaContraHead(dir, hash) {
|
|
|
280
281
|
}
|
|
281
282
|
// ── Registration ──────────────────────────────────────────────────────────────
|
|
282
283
|
export function registerTools(server, db) {
|
|
283
|
-
// Espejo del hospedado (T3 del benchmark 2026-07-20): definiciones
|
|
284
|
-
// exportadas por archivo, sin grep. Usos y tests siguen fuera y se dice.
|
|
285
|
-
server.registerTool("atlas_symbol_lookup", {
|
|
286
|
-
title: "Where is this symbol defined?",
|
|
287
|
-
description: `Files where an exported symbol (function/class/const/interface/type/enum) is DEFINED, with its kind and the commit that last touched it. Mechanical index built from every ingested diff.
|
|
288
|
-
|
|
289
|
-
Definitions only — usages and tests are not indexed; grep for those. Exact match first, substring fallback.
|
|
290
|
-
|
|
291
|
-
Args:
|
|
292
|
-
- symbol (required): the identifier to look up.
|
|
293
|
-
- project (recommended): the repo you are working in (folder name or slug).
|
|
294
|
-
|
|
295
|
-
Returns (structured): { symbol, exact_match, matches: [{ symbol, file, kind, commit }] }`,
|
|
296
|
-
inputSchema: {
|
|
297
|
-
symbol: z
|
|
298
|
-
.string()
|
|
299
|
-
.min(2)
|
|
300
|
-
.max(120)
|
|
301
|
-
.describe("Identifier to look up (exported definition)"),
|
|
302
|
-
project: z
|
|
303
|
-
.string()
|
|
304
|
-
.min(1)
|
|
305
|
-
.max(120)
|
|
306
|
-
.describe("Project to scope to (repo folder name or slug)"),
|
|
307
|
-
},
|
|
308
|
-
annotations: {
|
|
309
|
-
readOnlyHint: true,
|
|
310
|
-
destructiveHint: false,
|
|
311
|
-
idempotentHint: true,
|
|
312
|
-
openWorldHint: true,
|
|
313
|
-
},
|
|
314
|
-
}, async ({ symbol, project }) => {
|
|
315
|
-
try {
|
|
316
|
-
const t0 = Date.now();
|
|
317
|
-
const pf = await db.projectFilterFor(project);
|
|
318
|
-
const base = `symbol_index?select=symbol,file,kind,commit_hash&order=symbol.asc&limit=20` +
|
|
319
|
-
pf;
|
|
320
|
-
let matches = await db.rest(`${base}&symbol=eq.${encodeURIComponent(symbol)}`);
|
|
321
|
-
let exact = true;
|
|
322
|
-
if (matches.length === 0) {
|
|
323
|
-
exact = false;
|
|
324
|
-
matches = await db.rest(`${base}&symbol=ilike.${ilikePattern(symbol)}`);
|
|
325
|
-
}
|
|
326
|
-
const lines = [`# Symbol lookup: ${symbol}`, ""];
|
|
327
|
-
if (matches.length === 0) {
|
|
328
|
-
lines.push("Not in the index. It may be unexported, renamed, or defined before the index existed — fall back to grep and SAY you did, instead of guessing.");
|
|
329
|
-
}
|
|
330
|
-
for (const m of matches) {
|
|
331
|
-
lines.push(`- ${m.symbol} (${m.kind}) — ${m.file}${m.commit_hash ? ` · commit ${m.commit_hash.slice(0, 7)}` : ""}`);
|
|
332
|
-
}
|
|
333
|
-
if (matches.length > 0) {
|
|
334
|
-
lines.push("", "Definitions only — usages and tests are not indexed; grep for those.");
|
|
335
|
-
}
|
|
336
|
-
const salida = toolResult(lines.join("\n"), {
|
|
337
|
-
symbol,
|
|
338
|
-
exact_match: exact,
|
|
339
|
-
matches: matches.map((m) => ({
|
|
340
|
-
symbol: m.symbol,
|
|
341
|
-
file: m.file,
|
|
342
|
-
kind: m.kind,
|
|
343
|
-
commit: m.commit_hash?.slice(0, 7) ?? null,
|
|
344
|
-
})),
|
|
345
|
-
});
|
|
346
|
-
recordRead(db, "atlas_symbol_lookup", pf, servedCharsOf(salida), Date.now() - t0);
|
|
347
|
-
return salida;
|
|
348
|
-
}
|
|
349
|
-
catch (error) {
|
|
350
|
-
return errorResult(error);
|
|
351
|
-
}
|
|
352
|
-
});
|
|
353
284
|
server.registerTool("atlas_recent_changes", {
|
|
354
285
|
title: "Recent ChangeBook changes",
|
|
355
286
|
description: `List the most recent analyzed code changes from the ChangeBook changelog (newest first).
|
|
@@ -778,9 +709,14 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
|
|
|
778
709
|
for (const f of perFile) {
|
|
779
710
|
commitsByFile.set(f.file, recentCommitsForFile(f.changelogIds, commitById));
|
|
780
711
|
}
|
|
781
|
-
const [alerts, watched, recidivismRows, depsRows] = await Promise.all([
|
|
712
|
+
const [alerts, watched, recidivismRows, depsRows, coChangeRows] = await Promise.all([
|
|
782
713
|
moduleNames.length
|
|
783
|
-
? db.rest(
|
|
714
|
+
? db.rest(
|
|
715
|
+
// Con `evidence_scope`: sin él ni se refuta (alcanceDelRepo dice
|
|
716
|
+
// "sin_declarar" siempre) ni se sirve la línea NOT CHECKABLE
|
|
717
|
+
// HERE, que es la única forma de que un aviso sobre producción
|
|
718
|
+
// no se compruebe con un grep del repo. Ver guard.ts.
|
|
719
|
+
`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
720
|
pf)
|
|
785
721
|
: Promise.resolve([]),
|
|
786
722
|
// Constantes vigiladas (espejo del hospedado): el valor VIGENTE con
|
|
@@ -810,6 +746,23 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
|
|
|
810
746
|
.rest(`change_module?select=module,deps&deps=not.is.null&order=created_at.desc&limit=${MODULE_GRAPH_WINDOW_ROWS}` +
|
|
811
747
|
pf)
|
|
812
748
|
.catch(() => []),
|
|
749
|
+
// Co-cambio: qué se toca JUNTO en la práctica. Es la otra mitad del
|
|
750
|
+
// radio de impacto — `deps` es la arista DECLARADA (quién llama a
|
|
751
|
+
// quién) y esto es la HISTÓRICA (qué acabó cambiando a la vez), que
|
|
752
|
+
// solo sale del historial y es la señal que menos gente tiene.
|
|
753
|
+
//
|
|
754
|
+
// Es una CUARTA consulta y no se disimula. Lo que la hace aceptable es
|
|
755
|
+
// que entra en el MISMO Promise.all: en paralelo, la latencia de la
|
|
756
|
+
// tool es la de su consulta más lenta, no la suma — así que el p50
|
|
757
|
+
// solo sube si esta resulta ser la más lenta de las cuatro. Cuesta
|
|
758
|
+
// carga de servidor, no espera del agente.
|
|
759
|
+
//
|
|
760
|
+
// No se puede reutilizar la del grafo: aquella filtra `deps=not.is
|
|
761
|
+
// .null` y el co-cambio necesita TODAS las filas de la ventana.
|
|
762
|
+
db
|
|
763
|
+
.rest(`change_module?select=changelog_id,module,files&order=created_at.desc&limit=${MODULE_GRAPH_WINDOW_ROWS}` +
|
|
764
|
+
pf)
|
|
765
|
+
.catch(() => []),
|
|
813
766
|
]);
|
|
814
767
|
const watchedByFile = new Map();
|
|
815
768
|
for (const w of watched) {
|
|
@@ -860,6 +813,10 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
|
|
|
860
813
|
// dejar que se compruebe donde no era.
|
|
861
814
|
if (fuera)
|
|
862
815
|
extra += `\n → NOT CHECKABLE HERE: ${fuera}`;
|
|
816
|
+
// La linea que lo provoco. Mismas palabras que el hook (impactText).
|
|
817
|
+
if (a.evidence_line) {
|
|
818
|
+
extra += `\n → TRIGGERED BY: ${String(a.evidence_line).trim()}`;
|
|
819
|
+
}
|
|
863
820
|
porModulo.set(m, [...(porModulo.get(m) ?? []), { plain: a.plain, extra }]);
|
|
864
821
|
}
|
|
865
822
|
const alertsByModule = new Map();
|
|
@@ -871,6 +828,14 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
|
|
|
871
828
|
// Fuente única compartida (computeRecidivism) — antes 3 copias.
|
|
872
829
|
const recidivismByModule = computeRecidivism(recidivismRows);
|
|
873
830
|
const dependentsByModule = dependentsOf(moduleNames, depsRows);
|
|
831
|
+
// MISMA función que el bloque de CLAUDE.md y que el hook: una sola
|
|
832
|
+
// respuesta a "¿con qué cambia junto esto?". Sus umbrales viven en ella
|
|
833
|
+
// (3 apariciones, 60%, tope 5) y excluye los módulos de pruebas, que
|
|
834
|
+
// cambian con todo por construcción.
|
|
835
|
+
const parejas = coChangePairs(coChangeRows);
|
|
836
|
+
const coCambiosDe = (modulo) => parejas
|
|
837
|
+
.filter((p) => p.a === modulo || p.b === modulo)
|
|
838
|
+
.map((p) => ({ module: p.a === modulo ? p.b : p.a, rate: p.rate }));
|
|
874
839
|
// Ancla temporal: el último commit analizado del proyecto vs el HEAD
|
|
875
840
|
// de este árbol (misma puerta de proyecto que la refutación).
|
|
876
841
|
// Best-effort: el ancla jamás rompe la lectura que ancla.
|
|
@@ -917,6 +882,12 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
|
|
|
917
882
|
if (dependents?.length) {
|
|
918
883
|
lines.push(` - ↘ DEPENDS ON THIS: ${dependents.join(", ")} — check these too before you finish`);
|
|
919
884
|
}
|
|
885
|
+
// PALABRA POR PALABRA como el hook (impactText). Dos redacciones del
|
|
886
|
+
// mismo hecho son dos hechos para el agente, y aquí importa el doble
|
|
887
|
+
// porque el mismo agente ve las dos superficies en la misma sesión.
|
|
888
|
+
for (const cc of coCambiosDe(m.module)) {
|
|
889
|
+
lines.push(` - ↔ CHANGES WITH: ${cc.module} (together in ${Math.round(cc.rate * 100)}% of its changes) — check it before you finish`);
|
|
890
|
+
}
|
|
920
891
|
}
|
|
921
892
|
for (const w of watchedByFile.get(f.file) ?? []) {
|
|
922
893
|
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.6.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.6.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.6.0",
|
|
19
19
|
"transport": {
|
|
20
20
|
"type": "stdio"
|
|
21
21
|
}
|