changebook 0.4.9 → 0.4.10
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 +267 -23
- package/dist/impact.js +131 -13
- package/dist/import.js +2 -2
- package/dist/sync.js +76 -20
- package/dist/tools.js +36 -4
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/analyze.js
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import * as path from "node:path";
|
|
8
8
|
import { atlasWebUrl } from "./browser.js";
|
|
9
|
-
import { commitDiff, execFileAsync, FICHEROS_GENERADOS, GIT_MAX_BUFFER_BYTES, gitErrorMessage, MAX_DIFF_CHARACTERS, usableSummary, } from "./git.js";
|
|
9
|
+
import { commitDiff, execFileAsync, FICHEROS_GENERADOS, GIT_MAX_BUFFER_BYTES, gitErrorMessage, MAX_DIFF_CHARACTERS, projectNameFor, usableSummary, } from "./git.js";
|
|
10
10
|
import { canonicalDiffHash } from "./canonical.js";
|
|
11
11
|
import { optimizeTokensForAI, truncateAtFileBoundary } from "./optimize.js";
|
|
12
12
|
export async function analyze(db, options = {}) {
|
|
13
13
|
const cwd = path.resolve(options.dir ?? process.cwd());
|
|
14
|
-
const projectName =
|
|
14
|
+
const projectName = projectNameFor(cwd);
|
|
15
15
|
let rawDiff;
|
|
16
16
|
let commitHash;
|
|
17
17
|
let committedAt;
|
package/dist/git.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* normalization live here — a drift between them would make the same commit
|
|
5
5
|
* compress differently across subcommands and break the server-side dedup.
|
|
6
6
|
*/
|
|
7
|
-
import { execFile } from "node:child_process";
|
|
7
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
8
8
|
import * as path from "node:path";
|
|
9
9
|
import { promisify } from "node:util";
|
|
10
10
|
export const execFileAsync = promisify(execFile);
|
|
@@ -114,4 +114,47 @@ export function gitErrorMessage(error) {
|
|
|
114
114
|
}
|
|
115
115
|
return error instanceof Error ? error.message : String(error);
|
|
116
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* El nombre del proyecto al que reportar desde `dir`.
|
|
119
|
+
*
|
|
120
|
+
* NACE DE UN FLECO REAL. El 2026-07-25 trabajé en un worktree llamado `seo-wt` y
|
|
121
|
+
* el atlas creó un proyecto `seo-wt` en la cuenta de Raúl: gastó un análisis y
|
|
122
|
+
* dejó 4 módulos huérfanos en su selector de proyectos. No fue un bug del
|
|
123
|
+
* servidor: los cuatro sitios del CLI que deducen el nombre lo sacaban de
|
|
124
|
+
* `path.basename(cwd)`, y en un worktree eso NO es el nombre del repo.
|
|
125
|
+
*
|
|
126
|
+
* Un worktree enlazado es el MISMO repositorio: sus commits van al mismo sitio y
|
|
127
|
+
* su historia es la misma. Reportar a otro proyecto parte el atlas en dos por un
|
|
128
|
+
* detalle de cómo tienes montado el disco, y encima en silencio.
|
|
129
|
+
*
|
|
130
|
+
* Orden: `CHANGEBOOK_PROJECT` manda siempre (quien quiera un proyecto aparte por
|
|
131
|
+
* worktree lo dice y ya); si no, el nombre del árbol PRINCIPAL; y si no se puede
|
|
132
|
+
* averiguar, el basename de siempre.
|
|
133
|
+
*
|
|
134
|
+
* Se detecta comparando `--git-dir` con `--git-common-dir`: en el árbol principal
|
|
135
|
+
* son el mismo; en un worktree enlazado el primero es `.git/worktrees/<nombre>`.
|
|
136
|
+
*/
|
|
137
|
+
export function projectNameFor(dir, env = process.env) {
|
|
138
|
+
const explicito = env.CHANGEBOOK_PROJECT?.trim();
|
|
139
|
+
if (explicito)
|
|
140
|
+
return explicito;
|
|
141
|
+
const base = path.basename(path.resolve(dir));
|
|
142
|
+
try {
|
|
143
|
+
const gitDir = execFileSync("git", ["rev-parse", "--absolute-git-dir"], {
|
|
144
|
+
cwd: dir, encoding: "utf8", timeout: 5_000,
|
|
145
|
+
}).trim();
|
|
146
|
+
const comun = execFileSync("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], {
|
|
147
|
+
cwd: dir, encoding: "utf8", timeout: 5_000,
|
|
148
|
+
}).trim();
|
|
149
|
+
if (!gitDir || !comun || gitDir === comun)
|
|
150
|
+
return base;
|
|
151
|
+
// Worktree enlazado: el árbol principal es el padre del git-dir común.
|
|
152
|
+
const principal = path.basename(path.dirname(comun));
|
|
153
|
+
return principal || base;
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
// Sin git, o con una versión que no soporta estas banderas: como siempre.
|
|
157
|
+
return base;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
117
160
|
//# sourceMappingURL=git.js.map
|
package/dist/guard.js
CHANGED
|
@@ -13,22 +13,69 @@
|
|
|
13
13
|
* deliberately does NOT block.
|
|
14
14
|
*/
|
|
15
15
|
import * as fs from "node:fs";
|
|
16
|
-
import * as path from "node:path";
|
|
17
16
|
import { execFileSync } from "node:child_process";
|
|
18
|
-
import { execFileAsync, gitPath } from "./git.js";
|
|
17
|
+
import { execFileAsync, gitPath, projectNameFor } from "./git.js";
|
|
19
18
|
import { feedWarningFor } from "./feed.js";
|
|
20
19
|
/** Exit code that asks the pre-commit hook to abort the commit. */
|
|
21
20
|
export const EXIT_BLOCK = 3;
|
|
22
21
|
// A commit should never feel slow because of us: whatever the network hasn't
|
|
23
22
|
// answered by then is treated as "no findings".
|
|
24
23
|
const GUARD_TIMEOUT_MS = 3_500;
|
|
24
|
+
/**
|
|
25
|
+
* Tope de greps para decidir a QUIEN se avisa (ver `guardFindings`, nota 3).
|
|
26
|
+
*
|
|
27
|
+
* Mas alto que el tope de refutaciones del hook (3) porque aqui el grep decide
|
|
28
|
+
* si el aviso SALE, no solo si se cae: quedarse corto devuelve al guardian al
|
|
29
|
+
* comportamiento contaminado que estamos arreglando. Y sigue siendo barato —
|
|
30
|
+
* ~30 ms cada uno sobre este repo, cacheados por simbolo — dentro de los 3,5 s.
|
|
31
|
+
*/
|
|
32
|
+
const MAX_GREPS_DE_DESTINO = 8;
|
|
25
33
|
// Open alerts move at analysis speed (one per commit at most), so a short
|
|
26
34
|
// cache makes rebases/amend streaks free without risking stale warnings.
|
|
27
35
|
const CACHE_TTL_MS = 5 * 60_000;
|
|
28
36
|
const MAX_ALERTS = 20;
|
|
37
|
+
/**
|
|
38
|
+
* ¿Puede el REPO contestar la afirmacion de este aviso?
|
|
39
|
+
*
|
|
40
|
+
* Es la pregunta que faltaba, y la respuesta decide si el grep significa algo.
|
|
41
|
+
* Medido el 2026-07-26 sobre las 28 alertas con evidencia de AppAtlas: seis se
|
|
42
|
+
* descartaban en silencio porque su simbolo aparecia en el repo, y dos de ellas
|
|
43
|
+
* eran las UNICAS del registro que predijeron un incidente real —el desfase de
|
|
44
|
+
* esquema del 22 de julio, tres dias sin persistir alertas— porque hablaban de
|
|
45
|
+
* algo ausente en la BASE DE DATOS DE PRODUCCION. El simbolo estaba en el repo,
|
|
46
|
+
* claro: era el nombre del fichero de migracion.
|
|
47
|
+
*
|
|
48
|
+
* Una migracion presente en el repo NO prueba que su DDL haya corrido. Eso no se
|
|
49
|
+
* arregla con mejores greps; se arregla no haciendo el grep.
|
|
50
|
+
*/
|
|
51
|
+
export function alcanceDelRepo(alert) {
|
|
52
|
+
const scope = (alert.evidence_scope ?? "").trim();
|
|
53
|
+
if (scope === "repo")
|
|
54
|
+
return "si";
|
|
55
|
+
if (scope === "prod_db" || scope === "deployed")
|
|
56
|
+
return "no";
|
|
57
|
+
// Las 99 historicas y cualquier aviso donde el modelo lo omita. No se infiere
|
|
58
|
+
// del texto: adivinar el ambito es exactamente lo que rompio esto.
|
|
59
|
+
return "sin_declarar";
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* La frase para un aviso que el repo NO puede contestar. Es informacion, no
|
|
63
|
+
* ruido: le dice al agente donde SI habria que mirar, que en los dos casos
|
|
64
|
+
* medidos era "comprueba si la migracion corrio en produccion".
|
|
65
|
+
*/
|
|
66
|
+
export function dondeComprobarlo(alert) {
|
|
67
|
+
const scope = (alert.evidence_scope ?? "").trim();
|
|
68
|
+
if (scope === "prod_db") {
|
|
69
|
+
return "this claim is about the PRODUCTION DATABASE, not the repo — a migration file existing here does not mean its DDL ran";
|
|
70
|
+
}
|
|
71
|
+
if (scope === "deployed") {
|
|
72
|
+
return "this claim is about what is DEPLOYED right now, not the repo — check the deployed version, not the source";
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
29
76
|
/**
|
|
30
77
|
* Same slug the backend derives from the project name (analysis.ts slugify):
|
|
31
|
-
* the CLI sends `
|
|
78
|
+
* the CLI sends `projectNameFor(cwd)` as projectName and the server slugifies
|
|
32
79
|
* it, so reproducing that transform is how the guard finds its project row.
|
|
33
80
|
*/
|
|
34
81
|
export function slugifyProject(value) {
|
|
@@ -82,19 +129,60 @@ function alertFiles(alert) {
|
|
|
82
129
|
/**
|
|
83
130
|
* ¿Se contradice el aviso con el codigo que hay delante?
|
|
84
131
|
*
|
|
85
|
-
* Devuelve `true` SOLO cuando la evidencia lo tumba de forma
|
|
132
|
+
* Devuelve `true` SOLO cuando la evidencia lo tumba de forma INEQUIVOCA. Sin
|
|
86
133
|
* evidencia, con el simbolo vacio o si la busqueda falla, devuelve `false`: la
|
|
87
134
|
* duda deja pasar el aviso. Un guardian que se calla por un error de disco es
|
|
88
135
|
* peor que uno ruidoso — misma leccion que el limitador que fallaba abierto y
|
|
89
136
|
* nadie noto en nueve dias.
|
|
90
137
|
*
|
|
138
|
+
* ── 2026-07-26: LA RAMA "absent" NO ERA INEQUIVOCA Y SE QUITO ───────────────
|
|
139
|
+
*
|
|
140
|
+
* Decia: expect 'absent' + el simbolo aparece => refutado ("si aparece, ya esta
|
|
141
|
+
* hecho"). Medido sobre las 28 alertas con evidencia de AppAtlas:
|
|
142
|
+
*
|
|
143
|
+
* expect 'present' y el simbolo ya no esta -> descartar 0 veces
|
|
144
|
+
* expect 'absent' y el simbolo aparece -> descartar 6 veces
|
|
145
|
+
*
|
|
146
|
+
* O sea que el 100% de los descartes venia de esa rama, y la solida no disparaba
|
|
147
|
+
* NUNCA. Y las seis eran de las mejores del registro:
|
|
148
|
+
*
|
|
149
|
+
* · `alert_evidence` (22 jul): "esto deja regression_warnings vacio hasta que
|
|
150
|
+
* se aplique la migracion en produccion". SE CUMPLIO: tres dias sin
|
|
151
|
+
* persistir alertas.
|
|
152
|
+
* · `admin_install_metrics` (25 jul): el MISMO fallo otra vez, citando el
|
|
153
|
+
* precedente del 22. Una de las 5 alertas que alguien juzgo y arreglo.
|
|
154
|
+
*
|
|
155
|
+
* POR QUE FALLABA: "el simbolo aparece" significa dos cosas OPUESTAS — que el
|
|
156
|
+
* reemplazo se revirtio (el aviso ya no aplica) o que queda una referencia
|
|
157
|
+
* obsoleta (el aviso CUMPLIENDOSE). Y en esas seis, peor: hablaban de algo que
|
|
158
|
+
* falta en la BASE DE DATOS DE PRODUCCION, mientras el grep mira el REPO. El
|
|
159
|
+
* simbolo esta en el repo, claro que esta: es el nombre del fichero de
|
|
160
|
+
* migracion. Se buscaba en un sitio una afirmacion que era sobre otro.
|
|
161
|
+
*
|
|
162
|
+
* El caso que lo paga: cuando `vite.config.ts` todavia importaba `noscriptFor`,
|
|
163
|
+
* la alerta que lo nombraba se "refutaba" por aqui. Ese dia el build se rompio
|
|
164
|
+
* por eso exactamente.
|
|
165
|
+
*
|
|
166
|
+
* QUE SE HACE AHORA con ese caso: no se descarta, se SIRVE diciendo donde
|
|
167
|
+
* aparece el simbolo (ver `dondeApareceElSimbolo`). Nombrar el sitio es util en
|
|
168
|
+
* las dos lecturas y falso en ninguna, mientras que descartar es catastrofico en
|
|
169
|
+
* una de las dos. Es la misma regla de siempre —la duda deja pasar el aviso—
|
|
170
|
+
* aplicada donde no se estaba aplicando.
|
|
171
|
+
*
|
|
91
172
|
* `buscar` devuelve cuantas veces aparece el simbolo, o null si no se pudo
|
|
92
173
|
* mirar.
|
|
93
174
|
*/
|
|
94
175
|
export function avisoRefutado(alert, buscar) {
|
|
95
176
|
const simbolo = (alert.evidence_symbol ?? "").trim();
|
|
96
177
|
const espera = alert.evidence_expect;
|
|
97
|
-
|
|
178
|
+
// Solo 'present' puede refutarse con un grep del repo. 'absent' es ambiguo
|
|
179
|
+
// (ver arriba) y se resuelve sirviendo las rutas, no descartando.
|
|
180
|
+
if (!simbolo || espera !== "present")
|
|
181
|
+
return false;
|
|
182
|
+
// Y solo si la afirmacion es SOBRE el repo. Un 'sin_declarar' tampoco refuta:
|
|
183
|
+
// gatearlo no cuesta nada medido —esta rama disparo 0 veces en produccion— y
|
|
184
|
+
// quita el riesgo de tirar una alerta de produccion por buscar donde no era.
|
|
185
|
+
if (alcanceDelRepo(alert) !== "si")
|
|
98
186
|
return false;
|
|
99
187
|
let apariciones;
|
|
100
188
|
try {
|
|
@@ -108,10 +196,77 @@ export function avisoRefutado(alert, buscar) {
|
|
|
108
196
|
// "present": el aviso vive de que el simbolo siga ahi. Si ya no esta, el
|
|
109
197
|
// conflicto que describia no puede darse — es el caso de las 3 alertas del
|
|
110
198
|
// renombrado latestFilesByModule -> moduleFilesUnion: cero referencias.
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
199
|
+
return apariciones === 0;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Una linea por TEXTO: si dos avisos abiertos dicen lo mismo, se sirve uno.
|
|
203
|
+
*
|
|
204
|
+
* El dedup de creacion distingue por (simbolo, expectativa) a proposito — "tiene
|
|
205
|
+
* que seguir" y "tiene que desaparecer" son afirmaciones opuestas y colapsarlas
|
|
206
|
+
* escondería una regresion real. Pero eso es la IDENTIDAD, no la PANTALLA.
|
|
207
|
+
*
|
|
208
|
+
* Visto en produccion el 2026-07-27, servido al agente:
|
|
209
|
+
*
|
|
210
|
+
* ⚠ OPEN ALERT: La funcion noscriptFor fue reemplazada por crawlerBodyFor...
|
|
211
|
+
* ⚠ OPEN ALERT: La funcion noscriptFor fue reemplazada por crawlerBodyFor...
|
|
212
|
+
* → CHECKED NOW: ...
|
|
213
|
+
*
|
|
214
|
+
* Dos alertas de verdad distintas (una 'present', otra 'absent') con el MISMO
|
|
215
|
+
* texto, porque la expectativa no se muestra. Para quien lo lee es una
|
|
216
|
+
* repeticion, y un avisador que repite se ignora igual que uno que se equivoca.
|
|
217
|
+
* No se puede actuar distinto sobre dos frases identicas.
|
|
218
|
+
*
|
|
219
|
+
* `informativo` decide cual sobrevive: se prefiere el que trae la linea de
|
|
220
|
+
* comprobacion (donde aparece el simbolo, o donde habria que mirarlo), porque es
|
|
221
|
+
* el unico que añade algo. Empate: el primero, y el orden de entrada se respeta.
|
|
222
|
+
*/
|
|
223
|
+
export function unoPorTexto(items, texto, informativo) {
|
|
224
|
+
const mejor = new Map();
|
|
225
|
+
for (const it of items) {
|
|
226
|
+
const clave = texto(it).trim();
|
|
227
|
+
if (!clave)
|
|
228
|
+
continue;
|
|
229
|
+
const actual = mejor.get(clave);
|
|
230
|
+
if (!actual || (!informativo(actual) && informativo(it))) {
|
|
231
|
+
mejor.set(clave, it);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return [...mejor.values()];
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Donde aparece el simbolo de un aviso 'absent', para poder CONTESTAR el
|
|
238
|
+
* condicional en vez de vigilarlo.
|
|
239
|
+
*
|
|
240
|
+
* Es la otra mitad del cambio de arriba. El aviso de `noscriptFor` decia
|
|
241
|
+
* "actualiza cualquier referencia interna a noscriptFor ... SI LOS HUBIERA", y
|
|
242
|
+
* la respuesta era un grep. El 56% de los textos lleva un condicional asi
|
|
243
|
+
* ("si los hubiera", "podria", "sugiriendo"): en vez de prohibir la redaccion
|
|
244
|
+
* —que es tratar el sintoma— se resuelve la condicion y se dice el hecho.
|
|
245
|
+
*
|
|
246
|
+
* Devuelve null cuando no hay nada que añadir: otro `expect`, sin simbolo, la
|
|
247
|
+
* busqueda fallo, o el simbolo no aparece (y entonces el aviso se sirve tal
|
|
248
|
+
* cual, que es lo que ya hacia).
|
|
249
|
+
*/
|
|
250
|
+
export function dondeApareceElSimbolo(alert, buscarFicheros) {
|
|
251
|
+
const simbolo = (alert.evidence_symbol ?? "").trim();
|
|
252
|
+
if (!simbolo || alert.evidence_expect !== "absent")
|
|
253
|
+
return null;
|
|
254
|
+
// Con 'prod_db' o 'deployed' no se busca: decir "sigue apareciendo en X" sobre
|
|
255
|
+
// una afirmacion que no era del repo es afirmar algo falso con cara de hecho
|
|
256
|
+
// comprobado, que es peor que callarse. Para esos va `dondeComprobarlo`.
|
|
257
|
+
// 'sin_declarar' SI localiza: es aditivo y no puede perder un aviso.
|
|
258
|
+
if (alcanceDelRepo(alert) === "no")
|
|
259
|
+
return null;
|
|
260
|
+
let ficheros;
|
|
261
|
+
try {
|
|
262
|
+
ficheros = buscarFicheros(simbolo);
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
if (!ficheros || ficheros.length === 0)
|
|
268
|
+
return null;
|
|
269
|
+
return ficheros;
|
|
115
270
|
}
|
|
116
271
|
/**
|
|
117
272
|
* Open alerts × staged files → warnings, deduped by (module, message).
|
|
@@ -126,8 +281,32 @@ export function avisoRefutado(alert, buscar) {
|
|
|
126
281
|
*
|
|
127
282
|
* 2. SI SIGUE EN PIE. `refutado` lo decide quien llama, que es quien tiene el
|
|
128
283
|
* arbol de trabajo. De 7 avisos abiertos, 4 se caian con un grep.
|
|
284
|
+
*
|
|
285
|
+
* 3. DONDE VIVE EL SIMBOLO manda sobre la lista del aviso (2026-07-27), y esto
|
|
286
|
+
* nace de medir el guardian en 62 corridas reales. Aviso 2 veces; una en el
|
|
287
|
+
* clavo y otra falso positivo:
|
|
288
|
+
*
|
|
289
|
+
* La alerta era sobre `noscriptFor` en PUBLICACION (seoRoutes.ts,
|
|
290
|
+
* publish.yml) y salto al preparar `tools/bench-orientacion.mjs` — el arnes
|
|
291
|
+
* del benchmark, que no tiene nada que ver con noscriptFor.
|
|
292
|
+
*
|
|
293
|
+
* La causa: la lista `files` de un aviso son LOS FICHEROS DEL DIFF QUE LO
|
|
294
|
+
* LEVANTO, no los ficheros donde vive el simbolo. Medido sobre las 27 alertas
|
|
295
|
+
* con lista: **10 (37%) no nombran ni un fichero donde su simbolo este**. Y
|
|
296
|
+
* los peores contaminadores son los que se tocan a todas horas —
|
|
297
|
+
* `_shared/analysis.ts` sale en 5 listas, `bench-orientacion.mjs` en 4 —, o
|
|
298
|
+
* sea que cada vez que los editas arrastras avisos ajenos.
|
|
299
|
+
*
|
|
300
|
+
* Con `ficherosDelSimbolo` se pregunta donde esta el simbolo DE VERDAD. Es la
|
|
301
|
+
* misma pieza que sirve para contestar el condicional (`ficherosEnRepo`), aqui
|
|
302
|
+
* para decidir a quien se avisa.
|
|
303
|
+
*
|
|
304
|
+
* ORDEN DE PREFERENCIA, y cada escalon es un fallo-abierto del siguiente:
|
|
305
|
+
* donde vive el simbolo > lista del aviso > ficheros del modulo
|
|
306
|
+
* Si el grep falla o no encuentra nada se cae a la lista de siempre, asi que
|
|
307
|
+
* un fallo de disco no puede volver mudo al guardian.
|
|
129
308
|
*/
|
|
130
|
-
export function guardFindings(staged, alerts, filesByModule, refutado) {
|
|
309
|
+
export function guardFindings(staged, alerts, filesByModule, refutado, ficherosDelSimbolo) {
|
|
131
310
|
const stagedSet = new Set(staged);
|
|
132
311
|
const seen = new Set();
|
|
133
312
|
const findings = [];
|
|
@@ -136,10 +315,15 @@ export function guardFindings(staged, alerts, filesByModule, refutado) {
|
|
|
136
315
|
const plain = (alert.plain ?? "").trim();
|
|
137
316
|
if (!module || !plain)
|
|
138
317
|
continue;
|
|
139
|
-
//
|
|
140
|
-
// a
|
|
318
|
+
// Donde vive el simbolo manda sobre la lista del aviso, y la lista del aviso
|
|
319
|
+
// sobre el modulo: de mas preciso a menos. Ver la nota 3 de arriba.
|
|
320
|
+
const porSimbolo = ficherosDelSimbolo?.(alert) ?? null;
|
|
141
321
|
const propias = alertFiles(alert);
|
|
142
|
-
const ambito =
|
|
322
|
+
const ambito = porSimbolo && porSimbolo.length > 0
|
|
323
|
+
? porSimbolo
|
|
324
|
+
: propias.length > 0
|
|
325
|
+
? propias
|
|
326
|
+
: (filesByModule.get(module) ?? []);
|
|
143
327
|
const touched = ambito.filter((f) => stagedSet.has(f));
|
|
144
328
|
if (touched.length === 0)
|
|
145
329
|
continue;
|
|
@@ -167,13 +351,50 @@ export function guardFindings(staged, alerts, filesByModule, refutado) {
|
|
|
167
351
|
* `.` sueltos lo convertirian en otra expresion regular.
|
|
168
352
|
*/
|
|
169
353
|
export function contarEnRepo(dir, simbolo) {
|
|
354
|
+
const out = grepDelRepo(dir, simbolo, "--count");
|
|
355
|
+
if (out === null)
|
|
356
|
+
return null;
|
|
357
|
+
// Una linea "fichero:N" por fichero con coincidencias.
|
|
358
|
+
return out
|
|
359
|
+
.split("\n")
|
|
360
|
+
.filter(Boolean)
|
|
361
|
+
.reduce((n, l) => n + (Number(l.slice(l.lastIndexOf(":") + 1)) || 0), 0);
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* En QUE ficheros aparece el simbolo. Mismo grep, mismos pathspecs, distinta
|
|
365
|
+
* pregunta: `contarEnRepo` sirve para refutar y este para CONTESTAR (ver
|
|
366
|
+
* `dondeApareceElSimbolo`).
|
|
367
|
+
*
|
|
368
|
+
* Comparten `grepDelRepo` a proposito y no por ahorrar lineas: si divergieran
|
|
369
|
+
* los pathspecs, uno podria decir "no aparece" y el otro "aparece en X" sobre el
|
|
370
|
+
* mismo simbolo, y no habria forma de saber cual miente.
|
|
371
|
+
*/
|
|
372
|
+
export function ficherosEnRepo(dir, simbolo) {
|
|
373
|
+
const out = grepDelRepo(dir, simbolo, "--files-with-matches");
|
|
374
|
+
if (out === null)
|
|
375
|
+
return null;
|
|
376
|
+
return out.split("\n").filter(Boolean).slice(0, MAX_FICHEROS_SERVIDOS);
|
|
377
|
+
}
|
|
378
|
+
/** Tope de rutas que se nombran en un aviso: la lista es una pista, no un informe. */
|
|
379
|
+
const MAX_FICHEROS_SERVIDOS = 4;
|
|
380
|
+
/**
|
|
381
|
+
* El grep compartido. `modo` es `--count` o `--files-with-matches`.
|
|
382
|
+
*
|
|
383
|
+
* `git grep` y no un recorrido propio: respeta .gitignore, no entra en
|
|
384
|
+
* node_modules y esta escrito en C. Sobre este repo tarda ~30 ms, asi que cabe
|
|
385
|
+
* de sobra en el presupuesto de 3,5 s del guardian.
|
|
386
|
+
*
|
|
387
|
+
* `--fixed-strings` es obligatorio: el simbolo viene de un modelo y un `$` o un
|
|
388
|
+
* `.` sueltos lo convertirian en otra expresion regular.
|
|
389
|
+
*/
|
|
390
|
+
function grepDelRepo(dir, simbolo, modo) {
|
|
170
391
|
if (!/^[A-Za-z_$][\w$.]{1,118}$/.test(simbolo))
|
|
171
392
|
return null;
|
|
172
393
|
try {
|
|
173
|
-
|
|
394
|
+
return execFileSync("git", [
|
|
174
395
|
"grep",
|
|
175
396
|
"--fixed-strings",
|
|
176
|
-
|
|
397
|
+
modo,
|
|
177
398
|
"--",
|
|
178
399
|
simbolo,
|
|
179
400
|
// Se busca en CODIGO, nunca en prosa. Sin esto el mecanismo nace
|
|
@@ -188,17 +409,12 @@ export function contarEnRepo(dir, simbolo) {
|
|
|
188
409
|
// en silencio. Lo cazó el fixture hermético del test (2026-07-21).
|
|
189
410
|
":!docs/**",
|
|
190
411
|
], { cwd: dir, encoding: "utf8", timeout: 2_000, maxBuffer: 4 * 1024 * 1024 });
|
|
191
|
-
// Una linea "fichero:N" por fichero con coincidencias.
|
|
192
|
-
return out
|
|
193
|
-
.split("\n")
|
|
194
|
-
.filter(Boolean)
|
|
195
|
-
.reduce((n, l) => n + (Number(l.slice(l.lastIndexOf(":") + 1)) || 0), 0);
|
|
196
412
|
}
|
|
197
413
|
catch (e) {
|
|
198
414
|
// git grep sale con 1 cuando NO hay coincidencias: eso es un cero real, no
|
|
199
415
|
// un fallo. Cualquier otro codigo si es "no he podido mirar".
|
|
200
416
|
const code = e.status;
|
|
201
|
-
return code === 1 ?
|
|
417
|
+
return code === 1 ? "" : null;
|
|
202
418
|
}
|
|
203
419
|
}
|
|
204
420
|
export async function stagedFiles(dir) {
|
|
@@ -233,7 +449,7 @@ async function fetchSignals(db, dir, env) {
|
|
|
233
449
|
}
|
|
234
450
|
// Same project the analyze/hook pipeline reports to: CHANGEBOOK_PROJECT
|
|
235
451
|
// wins, otherwise the directory name, matched by server-side slug first.
|
|
236
|
-
const candidate =
|
|
452
|
+
const candidate = projectNameFor(dir, env);
|
|
237
453
|
const slug = slugifyProject(candidate);
|
|
238
454
|
let projects = slug
|
|
239
455
|
? await db.rest(`projects?select=id&slug=eq.${encodeURIComponent(slug)}&limit=1`)
|
|
@@ -367,7 +583,35 @@ export async function runGuard(db, dir, env = process.env) {
|
|
|
367
583
|
await logRun(dir, `timeout after ${GUARD_TIMEOUT_MS}ms — passing`);
|
|
368
584
|
return 0;
|
|
369
585
|
}
|
|
370
|
-
|
|
586
|
+
// Presupuesto de greps para decidir A QUIEN se avisa. `contarEnRepo` y
|
|
587
|
+
// `ficherosEnRepo` son execFileSync, o sea que BLOQUEAN el bucle de eventos y
|
|
588
|
+
// el techo de GUARD_TIMEOUT_MS no puede desalojarlos — un Promise.race no gana
|
|
589
|
+
// a una llamada sincrona. Con muchas alertas abiertas esto se comeria el
|
|
590
|
+
// presupuesto del commit a ~30 ms por grep. Pasado el tope se cae a la lista
|
|
591
|
+
// del aviso, que es el comportamiento de siempre.
|
|
592
|
+
//
|
|
593
|
+
// Se cachea por simbolo porque varias alertas comparten el mismo (noscriptFor
|
|
594
|
+
// salio 3 veces): sin esto se pagaria el mismo grep tres veces.
|
|
595
|
+
let grepsRestantes = MAX_GREPS_DE_DESTINO;
|
|
596
|
+
const cacheSimbolo = new Map();
|
|
597
|
+
const ficherosDelSimbolo = (alert) => {
|
|
598
|
+
const simbolo = (alert.evidence_symbol ?? "").trim();
|
|
599
|
+
if (!simbolo)
|
|
600
|
+
return null;
|
|
601
|
+
// Misma puerta que la refutacion: en 'prod_db' o 'deployed' el repo no dice
|
|
602
|
+
// donde vive la afirmacion, asi que apuntar con el grep seria apuntar mal.
|
|
603
|
+
if (alcanceDelRepo(alert) === "no")
|
|
604
|
+
return null;
|
|
605
|
+
if (cacheSimbolo.has(simbolo))
|
|
606
|
+
return cacheSimbolo.get(simbolo) ?? null;
|
|
607
|
+
if (grepsRestantes <= 0)
|
|
608
|
+
return null;
|
|
609
|
+
grepsRestantes -= 1;
|
|
610
|
+
const encontrados = ficherosEnRepo(dir, simbolo);
|
|
611
|
+
cacheSimbolo.set(simbolo, encontrados);
|
|
612
|
+
return encontrados;
|
|
613
|
+
};
|
|
614
|
+
const findings = guardFindings(staged, signals.alerts, signals.filesByModule, (alert) => avisoRefutado(alert, (simbolo) => contarEnRepo(dir, simbolo)), ficherosDelSimbolo);
|
|
371
615
|
const block = mode === "block";
|
|
372
616
|
const message = findingsMessage(findings, block);
|
|
373
617
|
// La consulta del guardián también es una consulta del atlas (QA
|
package/dist/impact.js
CHANGED
|
@@ -41,8 +41,8 @@ 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, ficherosEnRepo, slugifyProject, unoPorTexto, } from "./guard.js";
|
|
46
46
|
import { computeRecidivism, dependentsOf } from "./tools.js";
|
|
47
47
|
/**
|
|
48
48
|
* Techo duro del camino crítico. Más corto que el de `context` (2 s) porque
|
|
@@ -66,6 +66,29 @@ const SEEN_TTL_MS = 30 * 60_000;
|
|
|
66
66
|
const SEEN_MAX = 200;
|
|
67
67
|
/** Cuántos módulos se describen si el archivo pertenece a varios. */
|
|
68
68
|
const MAX_MODULES = 3;
|
|
69
|
+
/**
|
|
70
|
+
* Cuánto de la nota del análisis se sirve.
|
|
71
|
+
*
|
|
72
|
+
* SE SIRVE, y ese es el cambio del 2026-07-26 por la tarde. El hook nacía
|
|
73
|
+
* escueto a propósito —dependientes, alertas, reincidencia— y las notas se
|
|
74
|
+
* quedaron fuera por miedo al ruido. Midiendo se vio el coste de esa decisión:
|
|
75
|
+
* la nota de `Carga y arranque de la aplicación` sobre sync.ts dice «el
|
|
76
|
+
* SYNC_BUDGET_CHARS se rebaja de 3.000 a 2.000 porque la prosa gorda expulsaba
|
|
77
|
+
* el mapa», que es EXACTAMENTE el fallo en el que caí dos veces ese mismo día. Y
|
|
78
|
+
* es lo único que ningún grep puede encontrar: no está en el código, está en la
|
|
79
|
+
* historia del análisis. El canal estaba construido y servía lo que git ya sabe.
|
|
80
|
+
*
|
|
81
|
+
* Acotada, no libre: 320 chars por nota y solo en los módulos que YA se
|
|
82
|
+
* reportan (los que tienen dependientes, alerta abierta o reincidencia). Sin el
|
|
83
|
+
* tope, un archivo de diez módulos volcaría diez párrafos y el hook volvería a
|
|
84
|
+
* ser ruido que se aprende a ignorar.
|
|
85
|
+
*
|
|
86
|
+
* 320 y no 220: con 220, la nota de sync.ts se cortaba en «…porque la prosa
|
|
87
|
+
* gord…», justo antes de la conclusión. Estas notas describen primero y concluyen
|
|
88
|
+
* al final, así que un tope corto se come exactamente la carga útil y deja la
|
|
89
|
+
* paja. Tres notas de 320 son ~250 tokens una vez por archivo y sesión.
|
|
90
|
+
*/
|
|
91
|
+
const MAX_NOTE_CHARS = 320;
|
|
69
92
|
/** Tope de greps de refutación: son síncronos y el techo no los desaloja. */
|
|
70
93
|
const MAX_REFUTACIONES = 3;
|
|
71
94
|
export const IMPACT_HOOK = {
|
|
@@ -170,8 +193,36 @@ export function impactText(file, modules) {
|
|
|
170
193
|
if (m.dependents.length > 0) {
|
|
171
194
|
lines.push(` - ↘ DEPENDS ON THIS: ${m.dependents.join(", ")} — check these too before you finish`);
|
|
172
195
|
}
|
|
173
|
-
for (const
|
|
174
|
-
lines.push(` - ⚠ OPEN ALERT: ${plain}`);
|
|
196
|
+
for (const a of m.alerts) {
|
|
197
|
+
lines.push(` - ⚠ OPEN ALERT: ${a.plain}`);
|
|
198
|
+
// La condicion, resuelta. Va en su propia linea y con el simbolo delante
|
|
199
|
+
// para que se lea como un hecho comprobado y no como parte de la prosa del
|
|
200
|
+
// modelo, que es justo la que hedgea.
|
|
201
|
+
//
|
|
202
|
+
// Y DICE LAS DOS LECTURAS a proposito, porque el hecho no distingue: un
|
|
203
|
+
// simbolo que sigue apareciendo cuando se esperaba ausente puede ser una
|
|
204
|
+
// referencia que se quedo sin actualizar (el caso `noscriptFor`, que rompio
|
|
205
|
+
// el build) o un aviso que ya no vale (el caso `registerTools`). Antes se
|
|
206
|
+
// elegia la segunda en silencio y se tiraba el aviso; ahora se le dan al
|
|
207
|
+
// agente el hecho y las dos salidas, que es mas informacion que la que
|
|
208
|
+
// habia en cualquiera de las dos versiones anteriores.
|
|
209
|
+
if (a.donde && a.donde.length > 0) {
|
|
210
|
+
lines.push(` → CHECKED NOW: "${a.simbolo}" still appears in ${a.donde.join(", ")}` +
|
|
211
|
+
` — alert expects it gone: either a reference was missed, or the alert is stale`);
|
|
212
|
+
}
|
|
213
|
+
// El caso opuesto: el repo NO puede contestar esta afirmacion. Se dice, en
|
|
214
|
+
// vez de dejar que el agente (o el refutador) la compruebe donde no era —
|
|
215
|
+
// que es lo que tiraba las dos alertas del desfase de esquema.
|
|
216
|
+
if (a.fueraDelRepo) {
|
|
217
|
+
lines.push(` → NOT CHECKABLE HERE: ${a.fueraDelRepo}`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
// Con fecha y con la misma etiqueta que atlas_file_context: una nota es una
|
|
221
|
+
// observación fechada, no estado vigente. Va DESPUÉS de dependientes y
|
|
222
|
+
// alertas porque es contexto, no una orden.
|
|
223
|
+
if (m.note) {
|
|
224
|
+
lines.push(` - Note from last analysis${m.noteDate ? ` (${m.noteDate})` : ""}: ${m.note}`);
|
|
225
|
+
}
|
|
175
226
|
}
|
|
176
227
|
if (modules.length > MAX_MODULES) {
|
|
177
228
|
lines.push(`- (+${modules.length - MAX_MODULES} more module(s) affected)`);
|
|
@@ -216,10 +267,20 @@ export function modulosDelArchivo(file, rows) {
|
|
|
216
267
|
if (!label || out.has(label))
|
|
217
268
|
continue;
|
|
218
269
|
const files = Array.isArray(row.files) ? row.files.map(String) : [];
|
|
219
|
-
if (files.includes(file))
|
|
220
|
-
|
|
270
|
+
if (!files.includes(file))
|
|
271
|
+
continue;
|
|
272
|
+
const nota = (row.note ?? "").trim();
|
|
273
|
+
out.set(label, {
|
|
274
|
+
risk: row.risk,
|
|
275
|
+
note: nota
|
|
276
|
+
? nota.length > MAX_NOTE_CHARS
|
|
277
|
+
? `${nota.slice(0, MAX_NOTE_CHARS - 1).trimEnd()}…`
|
|
278
|
+
: nota
|
|
279
|
+
: null,
|
|
280
|
+
noteDate: (row.created_at ?? "").slice(0, 10) || null,
|
|
281
|
+
});
|
|
221
282
|
}
|
|
222
|
-
return [...out.entries()].map(([module,
|
|
283
|
+
return [...out.entries()].map(([module, v]) => ({ module, ...v }));
|
|
223
284
|
}
|
|
224
285
|
async function cachePath(dir) {
|
|
225
286
|
return gitPath(dir, "changebook-impact-cache.json").catch(() => null);
|
|
@@ -247,7 +308,7 @@ function escribirJson(file, data) {
|
|
|
247
308
|
async function fetchAtlas(db, dir, env) {
|
|
248
309
|
// Mismo proyecto al que reporta analyze/hook: CHANGEBOOK_PROJECT manda, si no
|
|
249
310
|
// el nombre del directorio, casado por slug del servidor primero.
|
|
250
|
-
const candidate =
|
|
311
|
+
const candidate = projectNameFor(dir, env);
|
|
251
312
|
const slug = slugifyProject(candidate);
|
|
252
313
|
let projects = slug
|
|
253
314
|
? await db.rest(`projects?select=id&slug=eq.${encodeURIComponent(slug)}&limit=1`)
|
|
@@ -275,7 +336,7 @@ async function fetchAtlas(db, dir, env) {
|
|
|
275
336
|
// reincidencia (all-time) salen del mismo conjunto.
|
|
276
337
|
const [rows, deps, alerts] = await Promise.all([
|
|
277
338
|
db
|
|
278
|
-
.rest(`change_module?select=module,files,risk,created_at&project_id=eq.${projectId}&order=created_at.desc&limit=${GRAPH_WINDOW_ROWS}`)
|
|
339
|
+
.rest(`change_module?select=module,files,risk,note,created_at&project_id=eq.${projectId}&order=created_at.desc&limit=${GRAPH_WINDOW_ROWS}`)
|
|
279
340
|
.catch(() => []),
|
|
280
341
|
db
|
|
281
342
|
.rest(`change_module?select=module,deps&project_id=eq.${projectId}&deps=not.is.null&order=created_at.desc&limit=${GRAPH_WINDOW_ROWS}`)
|
|
@@ -353,7 +414,21 @@ export async function warmImpactCache(db, dir, env = process.env) {
|
|
|
353
414
|
const cached = leerJson(file);
|
|
354
415
|
if (cached && Date.now() - cached.fetched_at < CACHE_TTL_MS)
|
|
355
416
|
return;
|
|
356
|
-
|
|
417
|
+
const fresco = await fetchAtlas(db, dir, env);
|
|
418
|
+
// Un calentamiento que NO resolvió el proyecto no se cachea.
|
|
419
|
+
//
|
|
420
|
+
// Cazado el 2026-07-26 montando el banco de pruebas: `impact --warm` sin
|
|
421
|
+
// CHANGEBOOK_PROJECT en un directorio cuyo nombre no es el del proyecto
|
|
422
|
+
// guardaba `{project_id: null, rows: []}`, y `atlasSignals` lo tomaba por
|
|
423
|
+
// caché válida durante CACHE_TTL_MS. Resultado: cinco minutos de silencio
|
|
424
|
+
// que se leen EXACTAMENTE igual que "no hay nada que decir" — la clase de
|
|
425
|
+
// fallo que este producto existe para cazar, dentro del producto.
|
|
426
|
+
//
|
|
427
|
+
// Sin cachearlo, el siguiente intento vuelve a preguntar. Cuesta un
|
|
428
|
+
// calentamiento desacoplado más; callar cinco minutos cuesta el canal.
|
|
429
|
+
if (!fresco.project_id)
|
|
430
|
+
return;
|
|
431
|
+
escribirJson(file, fresco);
|
|
357
432
|
}
|
|
358
433
|
catch {
|
|
359
434
|
// Nadie está mirando esta salida; el síntoma de un fallo aquí es que no hay
|
|
@@ -417,13 +492,28 @@ async function buildImpact(db, payload) {
|
|
|
417
492
|
// archivo con muchas alertas vivas podría comerse el presupuesto entero a
|
|
418
493
|
// 30 ms por grep. Tres es lo que cabe de sobra; el resto pasa sin refutar,
|
|
419
494
|
// que es el lado seguro (la duda deja pasar el aviso).
|
|
495
|
+
//
|
|
496
|
+
// El presupuesto lo comparten refutar y localizar, porque son el mismo grep
|
|
497
|
+
// sobre el mismo simbolo y solo uno de los dos aplica a cada aviso: 'present'
|
|
498
|
+
// se refuta, 'absent' se localiza (ver avisoRefutado). Nunca se gastan dos.
|
|
420
499
|
let grepsRestantes = MAX_REFUTACIONES;
|
|
421
500
|
const refutada = (a) => {
|
|
501
|
+
if (a.evidence_expect !== "present")
|
|
502
|
+
return false;
|
|
422
503
|
if (grepsRestantes <= 0)
|
|
423
504
|
return false;
|
|
424
505
|
grepsRestantes -= 1;
|
|
425
506
|
return avisoRefutado(a, (s) => contarEnRepo(toplevel, s));
|
|
426
507
|
};
|
|
508
|
+
/** Donde sigue apareciendo el simbolo de un aviso 'absent'. Contesta el condicional. */
|
|
509
|
+
const localizar = (a) => {
|
|
510
|
+
if (a.evidence_expect !== "absent")
|
|
511
|
+
return null;
|
|
512
|
+
if (grepsRestantes <= 0)
|
|
513
|
+
return null;
|
|
514
|
+
grepsRestantes -= 1;
|
|
515
|
+
return dondeApareceElSimbolo(a, (s) => ficherosEnRepo(toplevel, s));
|
|
516
|
+
};
|
|
427
517
|
const depsRows = signals.deps ?? [];
|
|
428
518
|
const bloques = [];
|
|
429
519
|
const avisadas = [];
|
|
@@ -436,14 +526,42 @@ async function buildImpact(db, payload) {
|
|
|
436
526
|
.map((m) => ({
|
|
437
527
|
module: m.module,
|
|
438
528
|
risk: m.risk,
|
|
529
|
+
note: m.note,
|
|
530
|
+
noteDate: m.noteDate,
|
|
439
531
|
dependents: dependientes.get(m.module) ?? [],
|
|
440
|
-
|
|
532
|
+
// Una linea por texto: dos avisos con la MISMA frase no le dejan al
|
|
533
|
+
// agente hacer nada distinto, por mucho que por dentro sean
|
|
534
|
+
// afirmaciones opuestas. Gana el que trae linea de comprobacion.
|
|
535
|
+
alerts: unoPorTexto(abiertas
|
|
441
536
|
.filter((a) => (a.module ?? "").trim() === m.module && a.plain)
|
|
442
537
|
.filter((a) => !refutada(a))
|
|
443
|
-
.map((a) =>
|
|
538
|
+
.map((a) => {
|
|
539
|
+
const donde = localizar(a);
|
|
540
|
+
const fuera = dondeComprobarlo(a);
|
|
541
|
+
return {
|
|
542
|
+
plain: a.plain,
|
|
543
|
+
...(donde
|
|
544
|
+
? { donde, simbolo: (a.evidence_symbol ?? "").trim() }
|
|
545
|
+
: {}),
|
|
546
|
+
...(fuera ? { fueraDelRepo: fuera } : {}),
|
|
547
|
+
};
|
|
548
|
+
}), (x) => x.plain, (x) => Boolean(x.donde || x.fueraDelRepo)),
|
|
444
549
|
priorRegressions: recidivism.get(m.module) ?? 0,
|
|
445
550
|
}))
|
|
446
|
-
.filter(valeLaPena)
|
|
551
|
+
.filter(valeLaPena)
|
|
552
|
+
// Por PELIGRO, no por lo más reciente. Solo caben MAX_MODULES y el orden
|
|
553
|
+
// decide qué se ve: con el orden por recencia, el módulo cuya nota decía
|
|
554
|
+
// «la prosa gorda expulsaba el mapa» —6 regresiones previas, y el fallo
|
|
555
|
+
// exacto en el que caí dos veces el 2026-07-26— quedaba en el «+N more».
|
|
556
|
+
// Servir la nota no vale nada si la nota que importa no entra.
|
|
557
|
+
//
|
|
558
|
+
// Total y determinista: el desempate por nombre mantiene el string estable
|
|
559
|
+
// entre sesiones, que es lo que evita pagar una escritura de caché a 1,25×
|
|
560
|
+
// en vez de una lectura a 0,1×.
|
|
561
|
+
.sort((a, b) => b.alerts.length - a.alerts.length ||
|
|
562
|
+
b.priorRegressions - a.priorRegressions ||
|
|
563
|
+
b.dependents.length - a.dependents.length ||
|
|
564
|
+
a.module.localeCompare(b.module));
|
|
447
565
|
if (impactos.length === 0)
|
|
448
566
|
continue;
|
|
449
567
|
bloques.push(impactText(file, impactos));
|
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/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];
|
package/dist/tools.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
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
12
|
const CHARACTER_LIMIT = 25_000;
|
|
13
13
|
/**
|
|
@@ -822,17 +822,49 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
|
|
|
822
822
|
// evidencia, sin repo o con la búsqueda rota, la alerta pasa; y si el
|
|
823
823
|
// cwd no es el proyecto consultado, no se refuta nada (ver
|
|
824
824
|
// cwdEsElProyecto).
|
|
825
|
-
const
|
|
825
|
+
const enElProyecto = cwdEsElProyecto(project);
|
|
826
|
+
const refutada = enElProyecto
|
|
826
827
|
? (a) => avisoRefutado(a, (s) => contarEnRepo(process.cwd(), s))
|
|
827
828
|
: () => false;
|
|
828
|
-
|
|
829
|
+
// La otra mitad, desde el 2026-07-26: un aviso 'absent' cuyo símbolo
|
|
830
|
+
// SIGUE apareciendo ya no se descarta (era ambiguo y tiraba las dos
|
|
831
|
+
// alertas que predijeron el desfase de esquema de producción). Se sirve
|
|
832
|
+
// diciendo dónde aparece, que es contestar el condicional del texto
|
|
833
|
+
// ("...si los hubiera") en vez de prohibir la redacción.
|
|
834
|
+
//
|
|
835
|
+
// Mismas palabras que el hook (impactText) a propósito: dos redacciones
|
|
836
|
+
// del mismo hecho son dos hechos para el agente.
|
|
837
|
+
const localizada = enElProyecto
|
|
838
|
+
? (a) => dondeApareceElSimbolo(a, (s) => ficherosEnRepo(process.cwd(), s))
|
|
839
|
+
: () => null;
|
|
840
|
+
// Se construye la lista ANTES de renderizar para poder dejar una linea
|
|
841
|
+
// por texto: dos avisos con la misma frase se leen como una repeticion
|
|
842
|
+
// aunque por dentro sean afirmaciones opuestas, y sobre dos frases
|
|
843
|
+
// identicas no se puede actuar distinto. Gana el que trae comprobacion.
|
|
844
|
+
const porModulo = new Map();
|
|
829
845
|
for (const a of alerts) {
|
|
830
846
|
const m = (a.module ?? "").trim();
|
|
831
847
|
if (!m || !a.plain)
|
|
832
848
|
continue;
|
|
833
849
|
if (refutada(a))
|
|
834
850
|
continue;
|
|
835
|
-
|
|
851
|
+
const donde = localizada(a);
|
|
852
|
+
const fuera = dondeComprobarlo(a);
|
|
853
|
+
let extra = "";
|
|
854
|
+
if (donde) {
|
|
855
|
+
extra +=
|
|
856
|
+
`\n → CHECKED NOW: "${(a.evidence_symbol ?? "").trim()}" still appears in ${donde.join(", ")}` +
|
|
857
|
+
` — alert expects it gone: either a reference was missed, or the alert is stale`;
|
|
858
|
+
}
|
|
859
|
+
// El repo no puede contestar esta afirmacion: se dice, en vez de
|
|
860
|
+
// dejar que se compruebe donde no era.
|
|
861
|
+
if (fuera)
|
|
862
|
+
extra += `\n → NOT CHECKABLE HERE: ${fuera}`;
|
|
863
|
+
porModulo.set(m, [...(porModulo.get(m) ?? []), { plain: a.plain, extra }]);
|
|
864
|
+
}
|
|
865
|
+
const alertsByModule = new Map();
|
|
866
|
+
for (const [m, entradas] of porModulo) {
|
|
867
|
+
alertsByModule.set(m, unoPorTexto(entradas, (e) => e.plain, (e) => e.extra.length > 0).map((e) => e.plain + e.extra));
|
|
836
868
|
}
|
|
837
869
|
// Reincidencia: nº de problemas de regresión DISTINTOS por módulo
|
|
838
870
|
// (count(distinct plain), all-time), solo los con antecedentes (>= 2).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "changebook",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.10",
|
|
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.4.
|
|
5
|
+
"version": "0.4.10",
|
|
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.4.
|
|
18
|
+
"version": "0.4.10",
|
|
19
19
|
"transport": {
|
|
20
20
|
"type": "stdio"
|
|
21
21
|
}
|