changebook 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/aciertos.js +100 -0
- package/dist/aliasDeModulo.js +203 -0
- package/dist/badge.js +159 -0
- package/dist/context.js +7 -1
- package/dist/guard.js +96 -13
- package/dist/hookMudo.js +172 -0
- package/dist/impact.js +319 -25
- package/dist/import.js +26 -1
- package/dist/index.js +173 -5
- package/dist/pregunta.js +59 -0
- package/dist/reglas.js +299 -0
- package/dist/scan.js +427 -0
- package/dist/supabase.js +84 -1
- package/dist/sync.js +291 -24
- package/dist/tools.js +182 -114
- package/package.json +2 -2
- package/server.json +3 -3
package/dist/tools.js
CHANGED
|
@@ -4,12 +4,12 @@
|
|
|
4
4
|
* All tools are read-only queries over the user's own ChangeBook data
|
|
5
5
|
* (Row Level Security scopes every request to the signed-in user).
|
|
6
6
|
*/
|
|
7
|
-
import path from "node:path";
|
|
8
7
|
import { z } from "zod";
|
|
9
|
-
import { execFileAsync } from "./git.js";
|
|
8
|
+
import { execFileAsync, projectNameFor } from "./git.js";
|
|
10
9
|
import { avisoRefutado, contarEnRepo, dondeApareceElSimbolo, dondeComprobarlo, ficherosEnRepo, slugifyProject, unoPorTexto, } from "./guard.js";
|
|
11
10
|
import { SupabaseError } from "./supabase.js";
|
|
12
11
|
import { coChangePairs } from "./sync.js";
|
|
12
|
+
import { aliasesFor, canonicalizeModuleRows, canonicalModule, etiquetaPorSlug, expandModuleNames, projectIdOf, slugModule, } from "./aliasDeModulo.js";
|
|
13
13
|
const CHARACTER_LIMIT = 25_000;
|
|
14
14
|
/**
|
|
15
15
|
* El contrato temporal de las respuestas del atlas (benchmark 2026-07-20: el
|
|
@@ -104,24 +104,40 @@ const MAX_DEPENDENTS = 6;
|
|
|
104
104
|
* o se pregunte al atlas. Paridad fijada en test/radioDeImpacto. */
|
|
105
105
|
const MODULE_GRAPH_WINDOW_ROWS = 1000;
|
|
106
106
|
export function dependentsOf(targets, rows) {
|
|
107
|
+
// POR SLUG, NO POR toLowerCase(): media normalizacion se lee igual que una
|
|
108
|
+
// entera (invariante 16). Espejo de supabase/functions/mcp/scope.ts.
|
|
109
|
+
const etiqueta = etiquetaPorSlug(rows.map((r) => r.module ?? ""));
|
|
110
|
+
// Memo de slugModule: se llama una vez por fila y por dep. Espejo de
|
|
111
|
+
// supabase/functions/mcp/scope.ts (medido: 0,510 ms -> 2,698 ms sin esto).
|
|
112
|
+
const memo = new Map();
|
|
113
|
+
const slug = (s) => {
|
|
114
|
+
let v = memo.get(s);
|
|
115
|
+
if (v === undefined) {
|
|
116
|
+
v = slugModule(s);
|
|
117
|
+
memo.set(s, v);
|
|
118
|
+
}
|
|
119
|
+
return v;
|
|
120
|
+
};
|
|
107
121
|
const graph = new Map();
|
|
108
122
|
for (const r of rows) {
|
|
109
123
|
const label = (r.module ?? "").trim();
|
|
110
|
-
if (!label || graph.has(label
|
|
124
|
+
if (!label || graph.has(slug(label)))
|
|
111
125
|
continue;
|
|
112
126
|
const deps = Array.isArray(r.deps)
|
|
113
127
|
? r.deps.map((d) => (typeof d === "string" ? d.trim() : "")).filter(Boolean)
|
|
114
128
|
: [];
|
|
115
|
-
graph.set(label
|
|
129
|
+
graph.set(slug(label), deps);
|
|
116
130
|
}
|
|
117
131
|
const reverse = new Map();
|
|
118
132
|
for (const r of rows) {
|
|
119
|
-
const
|
|
120
|
-
if (!
|
|
133
|
+
const crudo = (r.module ?? "").trim();
|
|
134
|
+
if (!crudo)
|
|
121
135
|
continue;
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
136
|
+
const slugFrom = slug(crudo);
|
|
137
|
+
const from = etiqueta.get(slugFrom) ?? crudo;
|
|
138
|
+
for (const to of graph.get(slugFrom) ?? []) {
|
|
139
|
+
const key = slug(to);
|
|
140
|
+
if (key === slugFrom)
|
|
125
141
|
continue;
|
|
126
142
|
const set = reverse.get(key) ?? new Set();
|
|
127
143
|
set.add(from);
|
|
@@ -133,7 +149,7 @@ export function dependentsOf(targets, rows) {
|
|
|
133
149
|
const label = (t ?? "").trim();
|
|
134
150
|
if (!label)
|
|
135
151
|
continue;
|
|
136
|
-
const found = reverse.get(label
|
|
152
|
+
const found = reverse.get(slug(label));
|
|
137
153
|
if (!found || found.size === 0)
|
|
138
154
|
continue;
|
|
139
155
|
out.set(label, [...found].sort((a, b) => a.localeCompare(b)).slice(0, MAX_DEPENDENTS));
|
|
@@ -147,18 +163,58 @@ export function dependentsOf(targets, rows) {
|
|
|
147
163
|
* antecedentes (>= 2). Plain distinto, no filas, para que el re-levantado no
|
|
148
164
|
* infle. No se refuta: cuenta historia, no vigencia.
|
|
149
165
|
*/
|
|
166
|
+
/**
|
|
167
|
+
* ESPEJO de `RecidivismMap` en supabase/functions/mcp/scope.ts. Un Map de
|
|
168
|
+
* verdad —se itera y se muestra con la etiqueta buena— cuyo `get`/`has`
|
|
169
|
+
* normalizan la clave por slug antes de buscar, para que ningún consumidor
|
|
170
|
+
* tenga que acordarse de canonicalizar. Ver allí el razonamiento entero.
|
|
171
|
+
*/
|
|
172
|
+
export class RecidivismMap extends Map {
|
|
173
|
+
#porSlug = new Map();
|
|
174
|
+
set(clave, valor) {
|
|
175
|
+
this.#porSlug.set(slugModule(clave), valor);
|
|
176
|
+
return super.set(clave, valor);
|
|
177
|
+
}
|
|
178
|
+
get(clave) {
|
|
179
|
+
return super.get(clave) ?? this.#porSlug.get(slugModule(clave));
|
|
180
|
+
}
|
|
181
|
+
has(clave) {
|
|
182
|
+
return super.has(clave) || this.#porSlug.has(slugModule(clave));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
150
185
|
export function computeRecidivism(rows) {
|
|
186
|
+
// Agrupado por slug y presentado con la etiqueta representante: si las
|
|
187
|
+
// alertas de un módulo partido están escritas con las dos grafías, sus
|
|
188
|
+
// regresiones distintas son las de UN módulo, no las de dos.
|
|
189
|
+
const etiqueta = etiquetaPorSlug(rows.map((r) => r.module ?? ""));
|
|
151
190
|
const plainsByModule = new Map();
|
|
152
191
|
for (const r of rows) {
|
|
153
|
-
|
|
192
|
+
// SOLO LAS CONFIRMADAS. Hasta el 2026-08-02 esto contaba TODA fila de
|
|
193
|
+
// `regression_alerts`, o sea avisos que el propio atlas se generó: sumaba
|
|
194
|
+
// los confirmados, los que el dueño refutó, los que se cerraron solos al
|
|
195
|
+
// re-tocar el módulo y los 79 que son anteriores al campo `resolution`.
|
|
196
|
+
// Medido en producción: decía 110 y las confirmadas eran 20 — «Análisis de
|
|
197
|
+
// cambios» salía con 13 y tenía CERO.
|
|
198
|
+
//
|
|
199
|
+
// La migración que añadió `resolution` (20260725100000) dice en su propio
|
|
200
|
+
// comentario que existe para impedir «el número bonito y falso». Este
|
|
201
|
+
// contador nunca lo adoptó. Ahora sí.
|
|
202
|
+
//
|
|
203
|
+
// `fixed` lo escribe el agente que actuó sobre el aviso, no un oráculo
|
|
204
|
+
// independiente, así que sigue siendo autocertificado — pero es la mejor
|
|
205
|
+
// etiqueta que hay, y desde luego mejor que no mirar.
|
|
206
|
+
if ((r.resolution ?? "").trim().toLowerCase() !== "fixed")
|
|
207
|
+
continue;
|
|
208
|
+
const crudo = (r.module ?? "").trim();
|
|
154
209
|
const p = (r.plain ?? "").trim();
|
|
155
|
-
if (!
|
|
210
|
+
if (!crudo || !p)
|
|
156
211
|
continue;
|
|
212
|
+
const m = etiqueta.get(slugModule(crudo)) ?? crudo;
|
|
157
213
|
const set = plainsByModule.get(m) ?? new Set();
|
|
158
214
|
set.add(p);
|
|
159
215
|
plainsByModule.set(m, set);
|
|
160
216
|
}
|
|
161
|
-
const out = new
|
|
217
|
+
const out = new RecidivismMap();
|
|
162
218
|
for (const [m, set] of plainsByModule) {
|
|
163
219
|
if (set.size >= 2)
|
|
164
220
|
out.set(m, set.size);
|
|
@@ -245,14 +301,20 @@ function ilikePattern(search) {
|
|
|
245
301
|
* `project` como argumento, pero el proceso corre donde el cliente lo arrancó.
|
|
246
302
|
* En un repo válido pero AJENO, un símbolo ausente da un 0 REAL (no null) y
|
|
247
303
|
* avisoRefutado con expect='present' lo tomaría como refutación — silenciar
|
|
248
|
-
* una alerta verdadera por mirar donde no era es peor que el ruido.
|
|
249
|
-
*
|
|
250
|
-
*
|
|
304
|
+
* una alerta verdadera por mirar donde no era es peor que el ruido.
|
|
305
|
+
*
|
|
306
|
+
* La identidad sale de `projectNameFor`, la MISMA que usan el guardián, el hook
|
|
307
|
+
* y sync: `CHANGEBOOK_PROJECT`, si no el nombre del árbol PRINCIPAL, y solo como
|
|
308
|
+
* último recurso el basename del cwd. Usar `path.basename(cwd)` a secas era el
|
|
309
|
+
* fallo: en un worktree enlazado el basename es el del worktree (`seo-wt`,
|
|
310
|
+
* `wt-3`…), NO el del repo, así que el grep de refutación y el ancla temporal se
|
|
311
|
+
* apagaban en todo worktree aunque fuera el mismo proyecto. `projectNameFor` ya
|
|
312
|
+
* resolvió esto para el resto del CLI el 2026-07-25; este gate se había quedado
|
|
313
|
+
* atrás.
|
|
251
314
|
*/
|
|
252
315
|
function cwdEsElProyecto(project) {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
return slugifyProject(candidato) === slugifyProject(project.trim());
|
|
316
|
+
return (slugifyProject(projectNameFor(process.cwd())) ===
|
|
317
|
+
slugifyProject(project.trim()));
|
|
256
318
|
}
|
|
257
319
|
/**
|
|
258
320
|
* "Atlas current up to commit X — your HEAD is N ahead": la señal MECÁNICA de
|
|
@@ -281,76 +343,6 @@ export async function derivaContraHead(dir, hash) {
|
|
|
281
343
|
}
|
|
282
344
|
// ── Registration ──────────────────────────────────────────────────────────────
|
|
283
345
|
export function registerTools(server, db) {
|
|
284
|
-
// Espejo del hospedado (T3 del benchmark 2026-07-20): definiciones
|
|
285
|
-
// exportadas por archivo, sin grep. Usos y tests siguen fuera y se dice.
|
|
286
|
-
server.registerTool("atlas_symbol_lookup", {
|
|
287
|
-
title: "Where is this symbol defined?",
|
|
288
|
-
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.
|
|
289
|
-
|
|
290
|
-
Definitions only — usages and tests are not indexed; grep for those. Exact match first, substring fallback.
|
|
291
|
-
|
|
292
|
-
Args:
|
|
293
|
-
- symbol (required): the identifier to look up.
|
|
294
|
-
- project (recommended): the repo you are working in (folder name or slug).
|
|
295
|
-
|
|
296
|
-
Returns (structured): { symbol, exact_match, matches: [{ symbol, file, kind, commit }] }`,
|
|
297
|
-
inputSchema: {
|
|
298
|
-
symbol: z
|
|
299
|
-
.string()
|
|
300
|
-
.min(2)
|
|
301
|
-
.max(120)
|
|
302
|
-
.describe("Identifier to look up (exported definition)"),
|
|
303
|
-
project: z
|
|
304
|
-
.string()
|
|
305
|
-
.min(1)
|
|
306
|
-
.max(120)
|
|
307
|
-
.describe("Project to scope to (repo folder name or slug)"),
|
|
308
|
-
},
|
|
309
|
-
annotations: {
|
|
310
|
-
readOnlyHint: true,
|
|
311
|
-
destructiveHint: false,
|
|
312
|
-
idempotentHint: true,
|
|
313
|
-
openWorldHint: true,
|
|
314
|
-
},
|
|
315
|
-
}, async ({ symbol, project }) => {
|
|
316
|
-
try {
|
|
317
|
-
const t0 = Date.now();
|
|
318
|
-
const pf = await db.projectFilterFor(project);
|
|
319
|
-
const base = `symbol_index?select=symbol,file,kind,commit_hash&order=symbol.asc&limit=20` +
|
|
320
|
-
pf;
|
|
321
|
-
let matches = await db.rest(`${base}&symbol=eq.${encodeURIComponent(symbol)}`);
|
|
322
|
-
let exact = true;
|
|
323
|
-
if (matches.length === 0) {
|
|
324
|
-
exact = false;
|
|
325
|
-
matches = await db.rest(`${base}&symbol=ilike.${ilikePattern(symbol)}`);
|
|
326
|
-
}
|
|
327
|
-
const lines = [`# Symbol lookup: ${symbol}`, ""];
|
|
328
|
-
if (matches.length === 0) {
|
|
329
|
-
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.");
|
|
330
|
-
}
|
|
331
|
-
for (const m of matches) {
|
|
332
|
-
lines.push(`- ${m.symbol} (${m.kind}) — ${m.file}${m.commit_hash ? ` · commit ${m.commit_hash.slice(0, 7)}` : ""}`);
|
|
333
|
-
}
|
|
334
|
-
if (matches.length > 0) {
|
|
335
|
-
lines.push("", "Definitions only — usages and tests are not indexed; grep for those.");
|
|
336
|
-
}
|
|
337
|
-
const salida = toolResult(lines.join("\n"), {
|
|
338
|
-
symbol,
|
|
339
|
-
exact_match: exact,
|
|
340
|
-
matches: matches.map((m) => ({
|
|
341
|
-
symbol: m.symbol,
|
|
342
|
-
file: m.file,
|
|
343
|
-
kind: m.kind,
|
|
344
|
-
commit: m.commit_hash?.slice(0, 7) ?? null,
|
|
345
|
-
})),
|
|
346
|
-
});
|
|
347
|
-
recordRead(db, "atlas_symbol_lookup", pf, servedCharsOf(salida), Date.now() - t0);
|
|
348
|
-
return salida;
|
|
349
|
-
}
|
|
350
|
-
catch (error) {
|
|
351
|
-
return errorResult(error);
|
|
352
|
-
}
|
|
353
|
-
});
|
|
354
346
|
server.registerTool("atlas_recent_changes", {
|
|
355
347
|
title: "Recent ChangeBook changes",
|
|
356
348
|
description: `List the most recent analyzed code changes from the ChangeBook changelog (newest first).
|
|
@@ -500,23 +492,42 @@ Returns (structured): { count, modules: [{ module, domain, risk, changes, last_c
|
|
|
500
492
|
// The aggregation below uses only these columns; note/tech/excerpt
|
|
501
493
|
// (up to ~1.5k each × 1000 rows) would move 1-2 MB per call for nothing.
|
|
502
494
|
`change_module?select=module,domain,risk,created_at` +
|
|
503
|
-
|
|
495
|
+
// LA CONSTANTE, no un 1000 a mano. Este era el tercer deletreo de la
|
|
496
|
+
// misma ventana en el CLI (la constante, este literal, y el
|
|
497
|
+
// MODULE_COUNT_WINDOW_ROWS del servidor), y el contrato que prohibe
|
|
498
|
+
// escribirla a mano solo leia los ficheros del servidor.
|
|
499
|
+
`&order=created_at.desc&limit=${MODULE_GRAPH_WINDOW_ROWS}` +
|
|
504
500
|
pf;
|
|
505
501
|
if (domain)
|
|
506
502
|
query += `&domain=eq.${encodeURIComponent(domain)}`;
|
|
507
|
-
|
|
503
|
+
// Canonicalizar ANTES de agrupar, o el catálogo listaría dos entradas
|
|
504
|
+
// para un módulo ya fusionado: la que más duele, porque el catálogo es
|
|
505
|
+
// justo la pantalla donde el dueño va a mirar si la fusión ha surtido
|
|
506
|
+
// efecto. Los alias van en el mismo Promise.all, sin ronda extra.
|
|
507
|
+
const [rowsCrudas, { aliases }] = await Promise.all([
|
|
508
|
+
db.rest(query),
|
|
509
|
+
aliasesFor(db, projectIdOf(pf)),
|
|
510
|
+
]);
|
|
511
|
+
const rows = canonicalizeModuleRows(rowsCrudas, aliases);
|
|
512
|
+
// POR SLUG, no por el nombre crudo — gemelo del arreglo del hospedado
|
|
513
|
+
// (03/08). Este catalogo servia un modulo escrito de dos maneras como
|
|
514
|
+
// dos modulos, y es la pantalla donde el dueño mira si su fusion ha
|
|
515
|
+
// surtido efecto. `canonicalizeModuleRows` resuelve ALIAS, que es otra
|
|
516
|
+
// cosa: no colapsa tildes ni mayusculas (invariante 16).
|
|
517
|
+
const etiqueta = etiquetaPorSlug(rows.map((r) => r.module ?? ''));
|
|
508
518
|
const byModule = new Map();
|
|
509
519
|
for (const row of rows) {
|
|
510
|
-
const
|
|
520
|
+
const clave = slugModule((row.module ?? '').trim());
|
|
521
|
+
const list = byModule.get(clave);
|
|
511
522
|
if (list)
|
|
512
523
|
list.push(row);
|
|
513
524
|
else
|
|
514
|
-
byModule.set(
|
|
525
|
+
byModule.set(clave, [row]);
|
|
515
526
|
}
|
|
516
|
-
const modules = [...byModule.entries()].map(([
|
|
527
|
+
const modules = [...byModule.entries()].map(([clave, list]) => {
|
|
517
528
|
const latest = list[0]; // rows arrive newest-first
|
|
518
529
|
return {
|
|
519
|
-
module:
|
|
530
|
+
module: etiqueta.get(clave) ?? latest.module,
|
|
520
531
|
domain: latest.domain,
|
|
521
532
|
risk: latest.risk,
|
|
522
533
|
changes: list.length,
|
|
@@ -525,7 +536,17 @@ Returns (structured): { count, modules: [{ module, domain, risk, changes, last_c
|
|
|
525
536
|
});
|
|
526
537
|
modules.sort((a, b) => (a.last_changed < b.last_changed ? 1 : -1));
|
|
527
538
|
const output = { count: modules.length, modules };
|
|
528
|
-
|
|
539
|
+
// LA VENTANA VA DICHA. Esto contaba "(N modules)" a secas y el brief
|
|
540
|
+
// mandaba aqui a por "el mapa completo": las dos cosas prometian de
|
|
541
|
+
// mas. Son los modulos vistos en los ultimos MODULE_GRAPH_WINDOW_ROWS
|
|
542
|
+
// cambios, y esa ventana estaba LLENA AL RAS el 03/08 (la suma de
|
|
543
|
+
// `changes` daba 1.000 clavados), o sea que el numero baja segun se
|
|
544
|
+
// trabaja. Un contador que se encoge cuando trabajas y se llama
|
|
545
|
+
// "total" dice lo contrario de lo que pasa.
|
|
546
|
+
const lines = [
|
|
547
|
+
`# ChangeBook module map (${modules.length} modules seen in the last ${MODULE_GRAPH_WINDOW_ROWS} changes)`,
|
|
548
|
+
"",
|
|
549
|
+
];
|
|
529
550
|
for (const m of modules) {
|
|
530
551
|
lines.push(`- **${m.module}**${m.domain ? ` (${m.domain})` : ""} — ` +
|
|
531
552
|
`${m.changes} change(s), last ${m.last_changed}` +
|
|
@@ -547,9 +568,7 @@ Returns (structured): { count, modules: [{ module, domain, risk, changes, last_c
|
|
|
547
568
|
});
|
|
548
569
|
server.registerTool("atlas_module_detail", {
|
|
549
570
|
title: "ChangeBook module detail",
|
|
550
|
-
description: `
|
|
551
|
-
|
|
552
|
-
This is the token-saving alternative to re-reading a module's source: it gives an agent the recent evolution and risk picture in one call.
|
|
571
|
+
description: `Call this WHEN you need a module's recent history instead of re-reading its source: what changed, why it matters, the files touched and (when available) a verbatim excerpt of each diff. The token-saving way to catch up on a module — its recent evolution and risk picture — in one call before you touch it.
|
|
553
572
|
|
|
554
573
|
Args:
|
|
555
574
|
- module (required): exact module name as returned by atlas_modules.
|
|
@@ -581,9 +600,18 @@ Returns (structured): { module, count, changes: [{ date, commit, risk, note, tec
|
|
|
581
600
|
try {
|
|
582
601
|
const t0 = Date.now();
|
|
583
602
|
const pf = await db.projectFilterFor(project);
|
|
584
|
-
|
|
585
|
-
|
|
603
|
+
// Los alias ANTES de la consulta, y aquí sí cuesta una ronda: el filtro
|
|
604
|
+
// por nombre va al servidor, así que no hay forma de expandirlo sin
|
|
605
|
+
// saber las hermanas. Se paga porque esta tool la invoca un agente de
|
|
606
|
+
// vez en cuando, no el guardián en cada edición — y porque la
|
|
607
|
+
// alternativa es que el historial de un módulo fusionado salga a medias
|
|
608
|
+
// sin decirlo, que es la peor forma de estar mal.
|
|
609
|
+
const { aliases, hermanas } = await aliasesFor(db, projectIdOf(pf));
|
|
610
|
+
const nombresParaConsultar = expandModuleNames([module], hermanas);
|
|
611
|
+
const rowsCrudas = await db.rest(`change_module?select=changelog_id,module,domain,category,risk,files,note,tech,excerpt,created_at` +
|
|
612
|
+
`&module=in.(${encodeURIComponent(quotedInList(nombresParaConsultar))})&order=created_at.desc&limit=${limit}` +
|
|
586
613
|
pf);
|
|
614
|
+
const rows = canonicalizeModuleRows(rowsCrudas, aliases);
|
|
587
615
|
if (rows.length === 0) {
|
|
588
616
|
return {
|
|
589
617
|
content: [
|
|
@@ -646,15 +674,20 @@ Returns (structured): { module, count, changes: [{ date, commit, risk, note, tec
|
|
|
646
674
|
}
|
|
647
675
|
}
|
|
648
676
|
const latest = rows[0];
|
|
677
|
+
// Se responde con el nombre CANÓNICO, no con el que preguntó el agente:
|
|
678
|
+
// preguntar por la etiqueta vieja de un módulo fusionado tiene que
|
|
679
|
+
// devolver su historia completa BAJO EL NOMBRE BUENO, o la respuesta
|
|
680
|
+
// enseñaría un nombre que ya no está en el mapa.
|
|
681
|
+
const nombreCanonico = canonicalModule(module, aliases);
|
|
649
682
|
const output = {
|
|
650
|
-
module,
|
|
683
|
+
module: nombreCanonico,
|
|
651
684
|
domain: latest.domain,
|
|
652
685
|
category: latest.category,
|
|
653
686
|
count: changes.length,
|
|
654
687
|
changes,
|
|
655
688
|
};
|
|
656
689
|
const lines = [
|
|
657
|
-
`# Module: ${
|
|
690
|
+
`# Module: ${nombreCanonico}` +
|
|
658
691
|
(latest.domain ? ` (${latest.domain}` +
|
|
659
692
|
(latest.category ? ` / ${latest.category}` : "") + ")" : ""),
|
|
660
693
|
];
|
|
@@ -692,9 +725,9 @@ Returns (structured): { module, count, changes: [{ date, commit, risk, note, tec
|
|
|
692
725
|
});
|
|
693
726
|
server.registerTool("atlas_file_context", {
|
|
694
727
|
title: "Context of the files you are about to edit",
|
|
695
|
-
description: `
|
|
728
|
+
description: `Call this BEFORE you edit, change, fix or refactor any file or function: it tells you how many times each file's module already broke here (recidivism) and its open regression alerts, so you touch it knowing what bit people last time. One cheap call instead of re-reading the code and its git history.
|
|
696
729
|
|
|
697
|
-
|
|
730
|
+
It also returns, per file: the module it belongs to and its risk, how often it changed recently, the recent commits that touched it (cite these instead of running git log), and the CURRENT value of watched config constants (with the commit it comes from — no need to re-read the file for those).
|
|
698
731
|
|
|
699
732
|
Args:
|
|
700
733
|
- files (required): 1-8 repo-relative paths.
|
|
@@ -717,10 +750,22 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
|
|
|
717
750
|
try {
|
|
718
751
|
const t0 = Date.now();
|
|
719
752
|
const pf = await db.projectFilterFor(project);
|
|
753
|
+
// La lectura de alias ARRANCA aquí y se espera dentro de cada fichero:
|
|
754
|
+
// corre en paralelo con las consultas por archivo, así que no cuesta
|
|
755
|
+
// ronda. Es la tool que el agente llama antes de cada edición. (Mismo
|
|
756
|
+
// patrón que el MCP hospedado, `aliasFileP`.)
|
|
757
|
+
const aliasP = aliasesFor(db, projectIdOf(pf));
|
|
720
758
|
const paths = [...new Set(files.map(normalizeRepoPath).filter(Boolean))];
|
|
721
759
|
const perFile = await Promise.all(paths.map(async (file) => {
|
|
722
|
-
const
|
|
723
|
-
|
|
760
|
+
const [rowsCrudas, { aliases }] = await Promise.all([
|
|
761
|
+
db.rest(`change_module?select=changelog_id,module,risk,note,created_at&${fileContainsFilter(file)}&order=created_at.desc&limit=20` +
|
|
762
|
+
pf),
|
|
763
|
+
aliasP,
|
|
764
|
+
]);
|
|
765
|
+
// ANTES de agrupar: el bucle de abajo cuenta cambios POR NOMBRE, y
|
|
766
|
+
// canonicalizar después dejaría el mismo módulo partido en dos
|
|
767
|
+
// entradas con la mitad de los cambios cada una.
|
|
768
|
+
const rows = canonicalizeModuleRows(rowsCrudas, aliases);
|
|
724
769
|
const byModule = new Map();
|
|
725
770
|
// Commits que tocaron ESTE archivo, del más nuevo al más viejo,
|
|
726
771
|
// deduplicados (un mismo análisis puede traer varias filas de
|
|
@@ -749,9 +794,15 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
|
|
|
749
794
|
}
|
|
750
795
|
return { file, modules: [...byModule.values()], changelogIds };
|
|
751
796
|
}));
|
|
797
|
+
// Los nombres YA canonicalizados: es lo que se enseña y con lo que se
|
|
798
|
+
// agrupa. Pero las consultas de abajo filtran EN EL SERVIDOR por
|
|
799
|
+
// nombre, y en la base siguen las etiquetas viejas, así que van con las
|
|
800
|
+
// hermanas — el alias se resuelve al leer, no reescribe nada.
|
|
801
|
+
const { aliases: aliasesFile, hermanas: hermanasFile } = await aliasP;
|
|
752
802
|
const moduleNames = [
|
|
753
803
|
...new Set(perFile.flatMap((f) => f.modules.map((m) => m.module))),
|
|
754
804
|
];
|
|
805
|
+
const nombresParaConsultar = expandModuleNames(moduleNames, hermanasFile);
|
|
755
806
|
// Commits recientes por archivo (T2: el agente que va a editar dejaba
|
|
756
807
|
// de necesitar `git log -- <archivo>`). UNA consulta batcheada para
|
|
757
808
|
// TODOS los archivos, no una por archivo: es la tool que corre antes
|
|
@@ -779,14 +830,14 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
|
|
|
779
830
|
for (const f of perFile) {
|
|
780
831
|
commitsByFile.set(f.file, recentCommitsForFile(f.changelogIds, commitById));
|
|
781
832
|
}
|
|
782
|
-
const [
|
|
833
|
+
const [alertsCrudas, watched, recidivismCrudas, depsCrudas, coChangeCrudas,] = await Promise.all([
|
|
783
834
|
moduleNames.length
|
|
784
835
|
? db.rest(
|
|
785
836
|
// Con `evidence_scope`: sin él ni se refuta (alcanceDelRepo dice
|
|
786
837
|
// "sin_declarar" siempre) ni se sirve la línea NOT CHECKABLE
|
|
787
838
|
// HERE, que es la única forma de que un aviso sobre producción
|
|
788
839
|
// 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(
|
|
840
|
+
`regression_alerts?select=module,plain,evidence_symbol,evidence_expect,evidence_scope,evidence_line&resolved_at=is.null&module=in.(${encodeURIComponent(quotedInList(nombresParaConsultar))})&order=created_at.desc&limit=10` +
|
|
790
841
|
pf)
|
|
791
842
|
: Promise.resolve([]),
|
|
792
843
|
// Constantes vigiladas (espejo del hospedado): el valor VIGENTE con
|
|
@@ -798,13 +849,22 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
|
|
|
798
849
|
pf)
|
|
799
850
|
.catch(() => []),
|
|
800
851
|
// Reincidencia (edit-time, espejo del hospedado): problemas de
|
|
801
|
-
// regresión de
|
|
802
|
-
//
|
|
803
|
-
//
|
|
804
|
-
//
|
|
852
|
+
// regresión CONFIRMADOS de todo el tiempo, de los módulos tocados.
|
|
853
|
+
// Se trae `resolution` porque el recuento solo cuenta las `fixed`:
|
|
854
|
+
// hasta el 2026-08-02 sumaba toda fila y decía 110 donde había 20.
|
|
855
|
+
// Depende de moduleNames como las alertas → mismo Promise.all, sin
|
|
856
|
+
// ronda extra. Se cuenta distinct plain abajo.
|
|
805
857
|
moduleNames.length
|
|
806
858
|
? db
|
|
807
|
-
.rest(
|
|
859
|
+
.rest(
|
|
860
|
+
// `order` añadido el 03/08 (AUD-C9), a la vez que en el
|
|
861
|
+
// hospedado: son las dos mitades de la paridad que vigila
|
|
862
|
+
// reincidenciaFileContext. Sin orden, el tope de 500 no daba
|
|
863
|
+
// «las 500 más recientes» sino 500 cualesquiera, distintas en
|
|
864
|
+
// cada llamada, así que la reincidencia que sale de aquí
|
|
865
|
+
// podía cambiar entre dos lecturas seguidas sin que la base
|
|
866
|
+
// hubiera cambiado.
|
|
867
|
+
`regression_alerts?select=module,plain,resolution&module=in.(${encodeURIComponent(quotedInList(nombresParaConsultar))})&order=created_at.desc&limit=500` +
|
|
808
868
|
pf)
|
|
809
869
|
.catch(() => [])
|
|
810
870
|
: Promise.resolve([]),
|
|
@@ -834,6 +894,14 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
|
|
|
834
894
|
pf)
|
|
835
895
|
.catch(() => []),
|
|
836
896
|
]);
|
|
897
|
+
// Los CUATRO conjuntos, sobre filas ya canonicalizadas. Si uno solo se
|
|
898
|
+
// quedara crudo, la respuesta hablaría de un módulo con la reincidencia
|
|
899
|
+
// o las dependencias de otro — y esta tool corre antes de cada edición,
|
|
900
|
+
// así que el error viajaría dentro de una decisión de código.
|
|
901
|
+
const alerts = canonicalizeModuleRows(alertsCrudas, aliasesFile);
|
|
902
|
+
const recidivismRows = canonicalizeModuleRows(recidivismCrudas, aliasesFile);
|
|
903
|
+
const depsRows = canonicalizeModuleRows(depsCrudas, aliasesFile);
|
|
904
|
+
const coChangeRows = canonicalizeModuleRows(coChangeCrudas, aliasesFile);
|
|
837
905
|
const watchedByFile = new Map();
|
|
838
906
|
for (const w of watched) {
|
|
839
907
|
watchedByFile.set(w.file, [...(watchedByFile.get(w.file) ?? []), w]);
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "changebook",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"mcpName": "io.github.raulbr90/changebook",
|
|
5
|
-
"description": "ChangeBook
|
|
5
|
+
"description": "Your agent already broke this three times. ChangeBook tells it before the fourth. MCP server + CLI: the history of what broke in your repo, served to Claude Code, Cursor or Codex before they edit.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "dist/index.js",
|
|
8
8
|
"bin": {
|
package/server.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
3
|
"name": "io.github.raulbr90/changebook",
|
|
4
|
-
"description": "
|
|
5
|
-
"version": "0.
|
|
4
|
+
"description": "Your agent already broke this three times. ChangeBook tells it before the fourth.",
|
|
5
|
+
"version": "0.7.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.7.0",
|
|
19
19
|
"transport": {
|
|
20
20
|
"type": "stdio"
|
|
21
21
|
}
|