changebook 0.7.0 → 0.8.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/git.js CHANGED
@@ -75,8 +75,9 @@ export function usableSummary(message) {
75
75
  * exactamente eso.
76
76
  *
77
77
  * Un commit que SOLO toca estos ficheros produce un diff vacío y `analyze` lo
78
- * salta con su mensaje de siempre — que es la respuesta correcta: regenerar el
79
- * mapa no es un cambio de producto.
78
+ * salta, que es la respuesta correcta: regenerar el mapa no es un cambio de
79
+ * producto. Lo que NO era correcto era cómo lo contaba — ver
80
+ * `ficherosDelCommit` aquí abajo y el mensaje en `analyze.ts`.
80
81
  */
81
82
  export const FICHEROS_GENERADOS = [
82
83
  ":(exclude)CLAUDE.md",
@@ -104,6 +105,45 @@ export async function commitDiff(cwd, hash) {
104
105
  throw new Error(`Could not read the diff of ${hash.slice(0, 8)}: ${gitErrorMessage(error)}`);
105
106
  }
106
107
  }
108
+ /**
109
+ * Los ficheros que toca un commit, SIN excluir los generados.
110
+ *
111
+ * POR QUÉ EXISTE. `commitDiff` excluye `CLAUDE.md`/`AGENTS.md`, así que un
112
+ * commit que solo toca esos devuelve diff vacío — igual que un merge, que no
113
+ * tiene diff propio. Dos situaciones distintas, una sola señal, y `analyze`
114
+ * elegía la equivocada: decía «has no analyzable diff (merge?)» de un commit
115
+ * normal que ni siquiera era un merge (medido el 04/08 con `0bac769`).
116
+ *
117
+ * Es la invariante 17 del repo dentro del producto: la ausencia de diff no
118
+ * prueba la ausencia de cambio mientras el instrumento no sepa distinguir por
119
+ * qué está ausente. Esta función es lo que le da esa segunda pregunta.
120
+ *
121
+ * Devuelve `[]` si el commit no toca ningún fichero — el caso del merge de
122
+ * verdad, y también el de un commit vacío.
123
+ */
124
+ export async function ficherosDelCommit(cwd, hash) {
125
+ try {
126
+ const { stdout } = await execFileAsync("git",
127
+ // Mismo `--end-of-options` que commitDiff, por el mismo motivo.
128
+ ["show", "--pretty=format:", "--name-only", "--end-of-options", hash], { cwd, encoding: "utf8", maxBuffer: GIT_MAX_BUFFER_BYTES });
129
+ return stdout
130
+ .split("\n")
131
+ .map((l) => l.trim())
132
+ .filter(Boolean);
133
+ }
134
+ catch (error) {
135
+ throw new Error(`Could not read the files of ${hash.slice(0, 8)}: ${gitErrorMessage(error)}`);
136
+ }
137
+ }
138
+ /**
139
+ * ¿Es un fichero de los que ChangeBook genera? Se decide con las MISMAS rutas
140
+ * que excluye `commitDiff`, derivadas de ella y no reescritas: dos listas que
141
+ * pudieran separarse harían que el mensaje explicara un salto que no ocurrió.
142
+ */
143
+ export function esFicheroGenerado(ruta) {
144
+ const nombre = ruta.split("/").pop() ?? "";
145
+ return FICHEROS_GENERADOS.some((patron) => patron.replace(/^:\(exclude\)(\*\*\/)?/, "") === nombre);
146
+ }
107
147
  /** Pulls git's stderr out of an execFile rejection for a readable message. */
108
148
  export function gitErrorMessage(error) {
109
149
  if (typeof error === "object" &&
package/dist/guard.js CHANGED
@@ -274,6 +274,151 @@ export function dondeApareceElSimbolo(alert, buscarFicheros) {
274
274
  return null;
275
275
  return ficheros;
276
276
  }
277
+ /**
278
+ * «El modelo nombro un simbolo que este cambio no toca», si el servidor lo
279
+ * comprobo y salio que no.
280
+ *
281
+ * ES LA SEÑAL QUE YA SE CALCULABA Y SE GUARDABA EN UN CAJON.
282
+ * `simboloFueraDelCambio` corre en cada analisis desde el 28/07 en modo SOMBRA:
283
+ * anota en `discarded_warnings` y el aviso se sirve igual. Medido el 08/08
284
+ * cruzandola con una auditoria a mano de los 21 avisos de FacelessOS, marco
285
+ * **15 de los 17 falsos**.
286
+ *
287
+ * POR QUE SE SIRVE EN VEZ DE DESCARTAR. Desde el servidor, un aviso legitimo
288
+ * sobre codigo de fuera del cambio —«alguien todavia llama a buildReport con el
289
+ * formato viejo»— es INDISTINGUIBLE del inventado: los dos afirman una relacion
290
+ * que el modelo no ha podido ver en el diff. Quien puede separarlos es este
291
+ * lado, que tiene el arbol: por eso esta linea viaja pegada a `fichaDelSimbolo`,
292
+ * que dice donde vive el simbolo de verdad. Juntas, el juicio se resuelve sin
293
+ * salir del aviso; por separado, cada una es media pista.
294
+ *
295
+ * SOLO HABLA CUANDO SABE. `true` no se dice —que el simbolo estuviera en el
296
+ * diff es lo normal y no es noticia— y `null`/ausente tampoco: puede ser un
297
+ * backfill, un servidor anterior a la columna o una cache vieja, y presentar
298
+ * «no se comprobo» como «esta comprobado» es la clase de silencio que este
299
+ * fichero entero existe para no cometer.
300
+ */
301
+ export function fueraDelCambio(alert) {
302
+ if (alert.evidence_in_diff !== false)
303
+ return null;
304
+ const simbolo = (alert.evidence_symbol ?? "").trim();
305
+ // LA FRASE DICE EXACTAMENTE LO QUE SE MIDIO, ni una palabra mas. El primer
306
+ // borrador decia «it is talking about code it did not see in the diff» y era
307
+ // FALSO: `simboloFueraDelCambio` solo mira las lineas AÑADIDAS Y QUITADAS, no
308
+ // las de contexto, asi que el modelo pudo verlo perfectamente. Lo cazo su
309
+ // propio contrato al pasarle un simbolo en linea de contexto. Una linea de
310
+ // comprobacion que afirma de mas es peor que no tenerla: se sirve con
311
+ // prioridad 0 y se lee como hecho verificado.
312
+ return (`${simbolo ? `"${simbolo}"` : "the alert's symbol"} is in no line this change` +
313
+ " added or removed — the claim may still hold, but nothing in this diff" +
314
+ " supports it");
315
+ }
316
+ /** Cuantos ficheros se nombran en la ficha antes de resumir. */
317
+ const MAX_FICHEROS_EN_FICHA = 3;
318
+ /**
319
+ * ¿Es esta linea la DEFINICION del simbolo, y esta exportada?
320
+ *
321
+ * Puro y separado del grep a proposito: la decision se puede probar con lineas
322
+ * escritas a mano, sin repo y sin disco. Conservador — solo reconoce la forma
323
+ * `[export] [default] [async] function|const|let|var|class|type|interface NOMBRE`,
324
+ * que es donde vive el 100% de los casos medidos. Un `module.exports.x = ...` o
325
+ * un `export { x }` a distancia NO se reconocen, y eso hace que la ficha diga
326
+ * "no exportado" de menos, jamas de mas... salvo por `export {`, que se mira
327
+ * aparte justo por eso.
328
+ */
329
+ export function defineElSimbolo(texto, simbolo) {
330
+ const esc = simbolo.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
331
+ const decl = new RegExp(`^\\s*(export\\s+)?(default\\s+)?(async\\s+)?(function|const|let|var|class|type|interface|enum)\\s+${esc}\\b`);
332
+ const m = decl.exec(texto);
333
+ if (m)
334
+ return { define: true, exportado: Boolean(m[1]) };
335
+ // `export { simbolo }` / `export { simbolo as otro }`: exporta sin declarar.
336
+ // Se mira porque su ausencia SI mentiria — diria "no exportado" de un simbolo
337
+ // que lo esta, y esa es la unica direccion en la que esta ficha puede hacer
338
+ // dano (invitar a concluir que no hay llamadores fuera cuando los hay).
339
+ if (new RegExp(`^\\s*export\\s*\\{[^}]*\\b${esc}\\b`).test(texto)) {
340
+ return { define: false, exportado: true };
341
+ }
342
+ return { define: false, exportado: false };
343
+ }
344
+ /**
345
+ * La ficha del simbolo de un aviso `present`: donde se define, si se exporta y
346
+ * en cuantos ficheros aparece.
347
+ *
348
+ * ── POR QUE EXISTE, y por que NO descarta ───────────────────────────────────
349
+ *
350
+ * Medido el 2026-08-08 auditando los 19 avisos abiertos de FacelessOS: 15 eran
351
+ * falsos y **8 de esos 15** decian lo mismo — «cualquier llamador que envie N
352
+ * argumentos fallara» — sobre funciones NO EXPORTADAS con UNA sola llamada, en
353
+ * su propio fichero. El aviso pedia comprobar algo que un grep contesta en 30 ms.
354
+ *
355
+ * La primera version de esta idea era un FILTRO que descartaba esos avisos. Se
356
+ * descarto al escribirla: contar apariciones no prueba que los llamadores se
357
+ * actualizaran —eso solo lo prueba el compilador, y el guardian no tiene
358
+ * presupuesto para un `tsc`—, asi que descartar puede tirar un aviso verdadero.
359
+ * Es exactamente la catastrofe por la que el 2026-07-26 se quito la rama
360
+ * 'absent' de `avisoRefutado`.
361
+ *
362
+ * Asi que se aplica a `present` la regla que ya estaba escrita para `absent`:
363
+ * **no descartar, CONTESTAR**. Nombrar el sitio es util en las dos lecturas y
364
+ * falso en ninguna; descartar es catastrofico en una de las dos.
365
+ *
366
+ * Y hay un segundo caso que esto caza solo, tambien medido ese dia: el aviso de
367
+ * `LIMITE_MS` apuntaba a «ai33.ts» y en ese repo hay TRES ficheros llamados
368
+ * ai33.ts con ese mismo simbolo. Decir «definido en 3 ficheros» deja ver el
369
+ * ancla ambigua sin tener que adivinar cual era.
370
+ *
371
+ * Devuelve null cuando no hay nada FIABLE que añadir: otro `expect`, sin
372
+ * simbolo, ambito que no es el repo, el grep fallo, o el simbolo no aparece.
373
+ */
374
+ export function fichaDelSimbolo(alert, buscarLineas) {
375
+ const simbolo = (alert.evidence_symbol ?? "").trim();
376
+ // El espejo exacto de `dondeApareceElSimbolo`, con el `expect` opuesto: alli
377
+ // se contesta «sigue apareciendo» a quien esperaba que se fuera; aqui se
378
+ // contesta «vive aqui y se le llama desde aqui» a quien avisa de llamadores.
379
+ if (!simbolo || alert.evidence_expect !== "present")
380
+ return null;
381
+ // Mismo gate que alli, y por lo mismo: una afirmacion sobre produccion no se
382
+ // contesta con un grep del repo. Para esas va `dondeComprobarlo`.
383
+ if (alcanceDelRepo(alert) !== "si")
384
+ return null;
385
+ let lineas;
386
+ try {
387
+ lineas = buscarLineas(simbolo);
388
+ }
389
+ catch {
390
+ return null;
391
+ }
392
+ if (!lineas || lineas.length === 0)
393
+ return null;
394
+ const ficheros = [...new Set(lineas.map((l) => l.fichero))];
395
+ const definiciones = [
396
+ ...new Set(lineas.filter((l) => defineElSimbolo(l.texto, simbolo).define).map((l) => l.fichero)),
397
+ ];
398
+ const exportado = lineas.some((l) => defineElSimbolo(l.texto, simbolo).exportado);
399
+ // Sin definicion a la vista no se dice nada: la ficha entera se apoya en
400
+ // saber DONDE vive el simbolo, y «aparece en 4 ficheros» sin saber cual lo
401
+ // declara es ruido que ya sirve `dondeApareceElSimbolo` para los 'absent'.
402
+ if (definiciones.length === 0)
403
+ return null;
404
+ const lista = (xs) => xs.length <= MAX_FICHEROS_EN_FICHA
405
+ ? xs.join(", ")
406
+ : `${xs.slice(0, MAX_FICHEROS_EN_FICHA).join(", ")} +${xs.length - MAX_FICHEROS_EN_FICHA}`;
407
+ // EL ANCLA AMBIGUA MANDA. Si el simbolo se declara en varios ficheros, el
408
+ // aviso no identifica uno y eso es lo primero que hay que saber: cualquier
409
+ // otra frase daria por buena una localizacion que no existe.
410
+ if (definiciones.length > 1) {
411
+ return (`"${simbolo}" is declared in ${definiciones.length} files (${lista(definiciones)})` +
412
+ ` — the alert names a symbol, not a file, so check which one it means`);
413
+ }
414
+ if (!exportado) {
415
+ return (`"${simbolo}" is declared in ${definiciones[0]} and is NOT exported;` +
416
+ ` it appears in ${ficheros.length} file(s) (${lista(ficheros)})` +
417
+ ` — every caller is in view there`);
418
+ }
419
+ return (`"${simbolo}" is exported from ${definiciones[0]}` +
420
+ ` and appears in ${ficheros.length} file(s) (${lista(ficheros)})`);
421
+ }
277
422
  /**
278
423
  * Open alerts × staged files → warnings, deduped by (module, message).
279
424
  *
@@ -397,6 +542,43 @@ export function ficherosEnRepo(dir, simbolo) {
397
542
  return null;
398
543
  return out.split("\n").filter(Boolean).slice(0, MAX_FICHEROS_SERVIDOS);
399
544
  }
545
+ /**
546
+ * Las LINEAS donde aparece el simbolo, para poder decir si se declara y si se
547
+ * exporta (ver `fichaDelSimbolo`). Tercera pregunta del mismo grep.
548
+ *
549
+ * El formato de `git grep -n` es `fichero:linea:texto`, y el texto puede llevar
550
+ * dos puntos, asi que se parte por los DOS primeros y nada mas. Partir por todos
551
+ * y quedarse con el ultimo trozo se comeria media linea de codigo — y una linea
552
+ * recortada por la mitad puede perder el `export` del principio, que es
553
+ * justamente lo que esto viene a leer.
554
+ */
555
+ export function lineasEnRepo(dir, simbolo) {
556
+ const out = grepDelRepo(dir, simbolo, "-n");
557
+ if (out === null)
558
+ return null;
559
+ const lineas = [];
560
+ for (const l of out.split("\n")) {
561
+ if (!l)
562
+ continue;
563
+ const p1 = l.indexOf(":");
564
+ if (p1 < 0)
565
+ continue;
566
+ const p2 = l.indexOf(":", p1 + 1);
567
+ if (p2 < 0)
568
+ continue;
569
+ lineas.push({ fichero: l.slice(0, p1), texto: l.slice(p2 + 1) });
570
+ if (lineas.length >= MAX_LINEAS_LEIDAS)
571
+ break;
572
+ }
573
+ return lineas;
574
+ }
575
+ /**
576
+ * Tope de lineas que se leen para hacer la ficha. Un simbolo corriente («id»,
577
+ * «data») puede salir miles de veces y la ficha solo necesita ver las
578
+ * declaraciones; leer sin tope convertiria una comprobacion de 30 ms en el
579
+ * cuello de botella del guardian.
580
+ */
581
+ const MAX_LINEAS_LEIDAS = 400;
400
582
  /** Tope de rutas que se nombran en un aviso: la lista es una pista, no un informe. */
401
583
  const MAX_FICHEROS_SERVIDOS = 4;
402
584
  /**
@@ -465,7 +647,7 @@ function grepDelRepo(dir, simbolo, modo) {
465
647
  * Empieza en 1: una caché SIN `v` es anterior al versionado y se descarta
466
648
  * siempre.
467
649
  */
468
- const GUARD_CACHE_V = 1;
650
+ export const GUARD_CACHE_V = 2;
469
651
  /**
470
652
  * Los hallazgos de B9 que tocan lo que se va a commitear.
471
653
  *
@@ -568,7 +750,7 @@ async function fetchSignals(db, dir, env) {
568
750
  // fuera barato. Lo vigila `test/alcanceLlegaAlCliente.test.ts`, que lee
569
751
  // ESTA línea: un test de comportamiento no lo caza, porque los stubs
570
752
  // inyectan el campo a mano.
571
- `regression_alerts?select=id,module,plain,created_at,evidence_symbol,evidence_expect,evidence_scope,evidence_line,files&project_id=eq.${projectId}&resolved_at=is.null&order=created_at.desc&limit=${MAX_ALERTS}`),
753
+ `regression_alerts?select=id,module,plain,created_at,evidence_symbol,evidence_expect,evidence_scope,evidence_line,evidence_in_diff,files&project_id=eq.${projectId}&resolved_at=is.null&order=created_at.desc&limit=${MAX_ALERTS}`),
572
754
  aliasesFor(db, projectId),
573
755
  ]);
574
756
  alerts = canonicalizeModuleRows(alertasCrudas, aliases);
package/dist/impact.js CHANGED
@@ -41,8 +41,9 @@ import { spawn } from "node:child_process";
41
41
  import * as fs from "node:fs";
42
42
  import * as path from "node:path";
43
43
  import { presupuestoDeTiempo } from "./context.js";
44
+ import { dirDeTranscripciones, drenarFriccion, pendientesDeShellPath, procesarFriccion, tomarPendientesDeShell, } from "./friction.js";
44
45
  import { execFileAsync, gitPath, projectNameFor } from "./git.js";
45
- import { avisoRefutado, contarEnRepo, dondeApareceElSimbolo, dondeComprobarlo, entregaDe, ficherosEnRepo, hashDelTexto, headShaDe, slugifyProject, unoPorTexto, } from "./guard.js";
46
+ import { avisoRefutado, contarEnRepo, dondeApareceElSimbolo, dondeComprobarlo, fueraDelCambio, fichaDelSimbolo, lineasEnRepo, entregaDe, ficherosEnRepo, hashDelTexto, headShaDe, slugifyProject, unoPorTexto, } from "./guard.js";
46
47
  import { aliasesFor, canonicalizeModuleRows, } from "./aliasDeModulo.js";
47
48
  import { coChangePairs } from "./sync.js";
48
49
  import { computeRecidivism, dependentsOf } from "./tools.js";
@@ -103,7 +104,11 @@ const SEEN_MAX = 200;
103
104
  * equivocados; un SeenState viejo, como mucho, repite un aviso o se calla uno
104
105
  * dentro de una sesión. No justifica tirar el estado de todo el mundo hoy.
105
106
  */
106
- const SEEN_STATE_V = 1;
107
+ // 1 2 EL 24/08: el estado pasó de guardar RUTAS a guardar MÓDULOS. Subirla es
108
+ // lo que hace que el estado viejo se olvide en vez de interpretarse mal —una
109
+ // ruta leída como módulo no casaría nunca y el dedup quedaría desactivado en
110
+ // silencio, que es justo el fallo que este cambio viene a arreglar.
111
+ const SEEN_STATE_V = 2;
107
112
  /** Cuántos módulos se describen si el archivo pertenece a varios. */
108
113
  const MAX_MODULES = 3;
109
114
  /**
@@ -412,6 +417,14 @@ export function lineasDeImpacto(file, modules) {
412
417
  ` — alert expects it gone: either a reference was missed, or the alert is stale`,
413
418
  });
414
419
  }
420
+ if (a.fueraDelCambio) {
421
+ lines.push({ prio: 0, text: ` → NOT IN THE DIFF: ${a.fueraDelCambio}` });
422
+ }
423
+ // La misma frase que sirve `atlas_file_context`, a proposito: dos
424
+ // redacciones del mismo hecho son dos hechos para el agente.
425
+ if (a.ficha) {
426
+ lines.push({ prio: 0, text: ` → CHECKED NOW: ${a.ficha}` });
427
+ }
415
428
  // El caso opuesto: el repo NO puede contestar esta afirmacion. Se dice, en
416
429
  // vez de dejar que el agente (o el refutador) la compruebe donde no era —
417
430
  // que es lo que tiraba las dos alertas del desfase de esquema.
@@ -447,29 +460,29 @@ export function lineasDeImpacto(file, modules) {
447
460
  return lines;
448
461
  }
449
462
  /**
450
- * Rutas que ya se avisaron en ESTA sesión y siguen frescas. Sesión distinta →
463
+ * Módulos que ya se avisaron en ESTA sesión y siguen frescos. Sesión distinta →
451
464
  * borrón y cuenta nueva: cada sesión abre con un contexto vacío, así que el
452
465
  * aviso vuelve a hacer falta.
453
466
  */
454
- export function rutasYaAvisadas(state, sessionId, ahora) {
467
+ export function modulosYaAvisados(state, sessionId, ahora) {
455
468
  if (!state || state.session_id !== sessionId)
456
469
  return new Set();
457
470
  // Otra forma de fichero: no se interpreta, se olvida. Ausente cuenta como la
458
471
  // actual (ver SEEN_STATE_V).
459
- if ((state.v ?? SEEN_STATE_V) !== SEEN_STATE_V)
472
+ if ((state.v ?? 1) !== SEEN_STATE_V)
460
473
  return new Set();
461
474
  const vivas = Object.entries(state.seen ?? {}).filter(([, at]) => typeof at === "number" && ahora - at < SEEN_TTL_MS);
462
- return new Set(vivas.map(([ruta]) => ruta));
475
+ return new Set(vivas.map(([modulo]) => modulo));
463
476
  }
464
477
  /** El estado siguiente, podado por TTL y por tamaño (más recientes primero). */
465
478
  export function estadoSiguiente(state, sessionId, nuevas, ahora) {
466
479
  const base = state &&
467
480
  state.session_id === sessionId &&
468
- (state.v ?? SEEN_STATE_V) === SEEN_STATE_V
481
+ (state.v ?? 1) === SEEN_STATE_V
469
482
  ? { ...(state.seen ?? {}) }
470
483
  : {};
471
- for (const ruta of nuevas)
472
- base[ruta] = ahora;
484
+ for (const modulo of nuevas)
485
+ base[modulo] = ahora;
473
486
  const podado = Object.entries(base)
474
487
  .filter(([, at]) => typeof at === "number" && ahora - at < SEEN_TTL_MS)
475
488
  .sort((a, b) => b[1] - a[1])
@@ -507,6 +520,48 @@ export function modulosDelArchivo(file, rows) {
507
520
  }
508
521
  return [...out.entries()].map(([module, v]) => ({ module, ...v }));
509
522
  }
523
+ /**
524
+ * El resolutor de módulos para el lector de fricción, sacado de la caché.
525
+ *
526
+ * Existe para que TODOS los caminos que encolan fricción resuelvan el módulo
527
+ * igual. Sin él, `changebook friction` encolaba con `modulo: null` y además
528
+ * avanzaba la marca de agua, así que esos bytes ya no los veía `--warm`: las
529
+ * filas subían sin módulo para siempre y el bloque del brief —que filtra por
530
+ * `module=not.is.null`— se quedaba ciego. Medido en prod el 24/08: 1.505 filas,
531
+ * cero con módulo.
532
+ *
533
+ * Sin caché devuelve `() => null` y se dice: es mejor una fila sin módulo que
534
+ * ninguna, pero quien llama puede avisar.
535
+ */
536
+ export async function resolutorDeModulos(dir) {
537
+ const file = await cachePath(dir).catch(() => null);
538
+ const cache = file ? leerJson(file) : null;
539
+ if (!cache?.rows?.length)
540
+ return { moduloDe: () => null, hayCache: false };
541
+ // `repoRelative` Y NO la ruta tal cual. EL ATLAS GUARDA RUTAS RELATIVAS A LA
542
+ // RAÍZ DEL REPO (lo dice el comentario de `atlasSignals`, y estaba escrito
543
+ // antes de que yo lo ignorara). La transcripción da absolutas, así que sin
544
+ // relativizar `files.includes(...)` NO CASA NUNCA: medido el 24/08, 0 de 1.505
545
+ // eventos salieron con módulo, en LOS DOS caminos.
546
+ //
547
+ // El toplevel de git y no `dir`: el agente puede estar en un subdirectorio.
548
+ // Mismo `rev-parse` que el hook, y con el mismo fallback: sin repo, `dir`.
549
+ const toplevel = await execFileAsync("git", ["rev-parse", "--show-toplevel"], {
550
+ cwd: dir,
551
+ encoding: "utf8",
552
+ })
553
+ .then(({ stdout }) => stdout.trim())
554
+ .catch(() => dir);
555
+ return {
556
+ moduloDe: (ruta) => {
557
+ const rel = repoRelative(toplevel, ruta);
558
+ if (!rel)
559
+ return null; // Fuera del repo: no es de ningún módulo.
560
+ return modulosDelArchivo(rel, cache.rows)[0]?.module ?? null;
561
+ },
562
+ hayCache: true,
563
+ };
564
+ }
510
565
  /**
511
566
  * ¿Se puede servir esta caché? Fresca Y de esta forma.
512
567
  *
@@ -966,7 +1021,7 @@ async function fetchAtlas(db, dir, env) {
966
1021
  // hace falta para poder atribuir después "se te avisó de esto" a la fila
967
1022
  // que lo dijo. Vienen en la misma consulta porque son gratis aquí y una
968
1023
  // caché escrita sin ellos sobreviviría 5 minutos a quien los espere.
969
- `regression_alerts?select=id,module,plain,resolution,created_at,files,resolved_at,evidence_symbol,evidence_expect,evidence_scope,evidence_line&project_id=eq.${projectId}&order=created_at.desc&limit=${ALERT_WINDOW_ROWS}`)
1024
+ `regression_alerts?select=id,module,plain,resolution,created_at,files,resolved_at,evidence_symbol,evidence_expect,evidence_scope,evidence_line,evidence_in_diff&project_id=eq.${projectId}&order=created_at.desc&limit=${ALERT_WINDOW_ROWS}`)
970
1025
  .catch(() => []),
971
1026
  aliasesFor(db, projectId),
972
1027
  ]);
@@ -1053,6 +1108,31 @@ export async function warmImpactCache(db, dir, env = process.env) {
1053
1108
  // seguiría avisando igual— y "¿acertó el aviso?" seguiría sin respuesta por
1054
1109
  // el canal más frecuente, que es justo lo que esto viene a arreglar.
1055
1110
  await drenarCola(db, dir);
1111
+ // La fricción va DESPUÉS del drenado y en su propio try: es la función
1112
+ // nueva, y la función nueva no puede tumbar el canal que ya funciona. Si
1113
+ // esto lanza, `atlas_reads` ya se vació y la caché se calienta igual.
1114
+ try {
1115
+ const cacheParaModulos = leerJson(file);
1116
+ // El MISMO resolutor que `changebook friction`. La versión anterior pasaba
1117
+ // la ruta cruda de la transcripción —absoluta— contra una caché de rutas
1118
+ // relativas, así que no resolvía NINGÚN módulo. Los dos caminos estaban
1119
+ // igual de roto, y unificarlos sin arreglar esto no cambió nada.
1120
+ const { moduloDe } = await resolutorDeModulos(dir);
1121
+ await procesarFriccion(dir, {
1122
+ ahoraMs: Date.now(),
1123
+ dirTranscripciones: dirDeTranscripciones(dir),
1124
+ moduloDe,
1125
+ });
1126
+ // `drenarFriccion` NO lanza: captura el fallo de subida, deja la cola
1127
+ // entera y lo anota para el diagnóstico. Si lanzara, este catch se lo
1128
+ // tragaría y un 403 se leería igual que «no había nada que subir».
1129
+ if (cacheParaModulos?.project_id) {
1130
+ await drenarFriccion(db, dir, cacheParaModulos.project_id);
1131
+ }
1132
+ }
1133
+ catch {
1134
+ // Ver arriba: aislamiento a propósito.
1135
+ }
1056
1136
  // Carrera entre dos ediciones seguidas: si otro proceso ya dejó una caché
1057
1137
  // fresca mientras este arrancaba, no gastar dos veces la misma consulta.
1058
1138
  const cached = leerJson(file);
@@ -1137,10 +1217,27 @@ marca = { contado: false }) {
1137
1217
  const ahora = Date.now();
1138
1218
  const sessionId = payload.session_id?.trim() || "sin-sesion";
1139
1219
  const seenFile = await gitPath(dir, "changebook-impact-seen.json").catch(() => null);
1140
- const yaAvisadas = rutasYaAvisadas(leerJson(seenFile), sessionId, ahora);
1141
- const pendientes = relativas.filter((r) => !yaAvisadas.has(r));
1142
- if (pendientes.length === 0)
1143
- return callar("ya-avisado");
1220
+ const modulosAvisados = modulosYaAvisados(leerJson(seenFile), sessionId, ahora);
1221
+ // TODOS los ficheros del repo siguen adelante: lo que se filtra ahora son los
1222
+ // MÓDULOS, más abajo. Un fichero que pertenece a un módulo del que aún no se
1223
+ // ha avisado tiene algo que decir aunque su ruta ya se haya visto.
1224
+ //
1225
+ // Y con ellos, LAS ESCRITURAS POR SHELL que el lector desacoplado apuntó: un
1226
+ // `sed -i` no pasa por este hook, así que su módulo no se avisaba jamás.
1227
+ // Medido el 24/08: 30 pares (sesión, módulo) tocados sólo por shell, de los
1228
+ // que ~19 valían un aviso —`Publicación` con 3 reincidencias entre ellos—.
1229
+ //
1230
+ // Se unen aquí y no en un hook de `Bash` porque esto NO cuesta latencia: la
1231
+ // lista ya está escrita en disco y sólo se lee. El dedup por módulo evita que
1232
+ // repitan lo que la edición en curso ya diga.
1233
+ const pendientes = [
1234
+ ...new Set([
1235
+ ...relativas,
1236
+ ...tomarPendientesDeShell(await pendientesDeShellPath(dir))
1237
+ .map((r) => repoRelative(toplevel, r))
1238
+ .filter((r) => Boolean(r)),
1239
+ ]),
1240
+ ];
1144
1241
  const signals = await atlasSignals(db, dir);
1145
1242
  if (!signals?.project_id)
1146
1243
  return callar("sin-atlas");
@@ -1204,6 +1301,20 @@ marca = { contado: false }) {
1204
1301
  grepsRestantes -= 1;
1205
1302
  return dondeApareceElSimbolo(a, (s) => ficherosEnRepo(toplevel, s));
1206
1303
  };
1304
+ /**
1305
+ * La ficha del simbolo de un aviso 'present'. Gasta del MISMO presupuesto de
1306
+ * greps que las otras dos: el guardian corre pre-commit contra un plazo, y una
1307
+ * comprobacion nueva que no lo respetara lo volveria lento justo cuando mas
1308
+ * avisos abiertos hay — que es cuando mas falta hace que conteste.
1309
+ */
1310
+ const fichar = (a) => {
1311
+ if (a.evidence_expect !== "present")
1312
+ return null;
1313
+ if (grepsRestantes <= 0)
1314
+ return null;
1315
+ grepsRestantes -= 1;
1316
+ return fichaDelSimbolo(a, (s) => lineasEnRepo(toplevel, s));
1317
+ };
1207
1318
  const depsRows = signals.deps ?? [];
1208
1319
  // Co-cambio: UNA vez por corrida, no por módulo. `coChangePairs` recorre la
1209
1320
  // ventana entera y ya trae sus propios umbrales (MIN_PAIR_COUNT=3,
@@ -1219,6 +1330,12 @@ marca = { contado: false }) {
1219
1330
  // Lineas con prioridad, no texto ya cocido: el techo se aplica al TOTAL, asi
1220
1331
  // que hay que poder recortar despues de saber cuanto ocupa todo junto.
1221
1332
  const lineas = [];
1333
+ /** Los MÓDULOS servidos: es lo que se recuerda para no repetir. */
1334
+ const modulosServidos = [];
1335
+ /** Los FICHEROS servidos: es lo que se anota en el libro de entregas.
1336
+ * Dos listas y no una: repurposar la columna `files` para que llevara
1337
+ * módulos habría cambiado el significado de un dato ya publicado sin
1338
+ * renombrarlo — la deriva silenciosa que este repo persigue. */
1222
1339
  const avisadas = [];
1223
1340
  /** Las alertas que de verdad salieron en el texto. Ver el encolado del final. */
1224
1341
  const idsServidos = [];
@@ -1226,6 +1343,14 @@ marca = { contado: false }) {
1226
1343
  * separa «no era de nadie» de «era de alguien y aun así no había nada que
1227
1344
  * decir», que son los dos silencios que la tasa NO puede mezclar. */
1228
1345
  let conModulo = 0;
1346
+ /** ¿Se calló algún módulo por haberse avisado ya en esta sesión?
1347
+ *
1348
+ * Lo necesita el contador de silencios: «ya avisado» y «no había nada que
1349
+ * decir» son motivos DISTINTOS, y mezclarlos deja la tasa sin poder explicar
1350
+ * por qué calló. Al pasar el dedup de fichero a módulo, el filtro dejó de
1351
+ * estar donde se emitía `ya-avisado` — y sin esta bandera los dos motivos se
1352
+ * volvían el mismo. */
1353
+ let algunoYaAvisado = false;
1229
1354
  for (const file of pendientes) {
1230
1355
  const modulos = modulosDelArchivo(file, signals.rows);
1231
1356
  if (modulos.length === 0)
@@ -1249,20 +1374,39 @@ marca = { contado: false }) {
1249
1374
  .map((a) => {
1250
1375
  const donde = localizar(a);
1251
1376
  const fuera = dondeComprobarlo(a);
1377
+ const ficha = fichar(a);
1378
+ // No gasta grep: es una columna que ya viene en la fila.
1379
+ const noVisto = fueraDelCambio(a);
1252
1380
  return {
1253
1381
  plain: a.plain,
1254
1382
  alertIds: a.id ? [a.id] : [],
1383
+ ...(noVisto ? { fueraDelCambio: noVisto } : {}),
1255
1384
  ...(donde
1256
1385
  ? { donde, simbolo: (a.evidence_symbol ?? "").trim() }
1257
1386
  : {}),
1387
+ ...(ficha ? { ficha } : {}),
1258
1388
  ...(fuera ? { fueraDelRepo: fuera } : {}),
1259
1389
  ...(a.evidence_line
1260
1390
  ? { linea: String(a.evidence_line).trim() }
1261
1391
  : {}),
1262
1392
  };
1263
- }), (x) => x.plain, (x) => Boolean(x.donde || x.fueraDelRepo)), abiertas.filter((a) => (a.module ?? "").trim() === m.module)),
1393
+ }), (x) => x.plain,
1394
+ // `ficha` cuenta como comprobacion igual que las otras dos: si dos
1395
+ // avisos comparten texto, sobrevive el que trae algo comprobado. Sin
1396
+ // añadirla aqui, el que la trae podia perder el desempate y la
1397
+ // comprobacion se tiraba junto con su duplicado.
1398
+ (x) => Boolean(x.donde || x.fueraDelRepo || x.ficha || x.fueraDelCambio)), abiertas.filter((a) => (a.module ?? "").trim() === m.module)),
1264
1399
  priorRegressions: recidivism.get(m.module) ?? 0,
1265
1400
  }))
1401
+ // El dedup, por MÓDULO y antes de `valeLaPena`: si de este módulo ya se
1402
+ // avisó en esta sesión, su texto sería idéntico y no deja hacer nada
1403
+ // distinto. Medido: la mitad de los avisos eran esto.
1404
+ .filter((m) => {
1405
+ if (!modulosAvisados.has(m.module))
1406
+ return true;
1407
+ algunoYaAvisado = true;
1408
+ return false;
1409
+ })
1266
1410
  .filter(valeLaPena)
1267
1411
  // Por PELIGRO, no por lo más reciente. Solo caben MAX_MODULES y el orden
1268
1412
  // decide qué se ve: con el orden por recencia, el módulo cuya nota decía
@@ -1280,8 +1424,12 @@ marca = { contado: false }) {
1280
1424
  if (impactos.length === 0)
1281
1425
  continue;
1282
1426
  lineas.push(...lineasDeImpacto(file, impactos));
1427
+ // Se recuerdan los MÓDULOS servidos, no el fichero. Y sólo los que de verdad
1428
+ // caben (`MAX_MODULES`): marcar como avisado uno que se quedó en el «+N
1429
+ // more» lo silenciaría sin haberlo dicho nunca.
1283
1430
  avisadas.push(file);
1284
1431
  for (const m of impactos.slice(0, MAX_MODULES)) {
1432
+ modulosServidos.push(m.module);
1285
1433
  idsServidos.push(...m.alerts.flatMap((a) => a.alertIds));
1286
1434
  }
1287
1435
  }
@@ -1294,13 +1442,20 @@ marca = { contado: false }) {
1294
1442
  // Solo se marca como avisado lo que de verdad se dijo: si el archivo no tenía
1295
1443
  // nada hoy pero mañana sale una alerta suya, el aviso tiene que poder salir.
1296
1444
  if (avisadas.length > 0) {
1297
- escribirJson(seenFile, estadoSiguiente(leerJson(seenFile), sessionId, avisadas, ahora));
1445
+ escribirJson(seenFile, estadoSiguiente(leerJson(seenFile), sessionId, modulosServidos, ahora));
1298
1446
  }
1299
1447
  // Los dos silencios, separados por `conModulo`. Sin esa distinción la tasa
1300
1448
  // sube sola cada vez que alguien edita un README y no dice nada de la
1301
1449
  // puntería del hook, que es lo único que se quería medir.
1302
1450
  if (lineas.length === 0) {
1303
- return callar(conModulo === 0 ? "fuera-de-modulo" : "sin-lineas");
1451
+ if (conModulo === 0)
1452
+ return callar("fuera-de-modulo");
1453
+ // `ya-avisado` ANTES de `sin-lineas`, y el orden es el punto: si el silencio
1454
+ // vino de que sus módulos ya se dijeron, ése es el motivo. Al pasar el dedup
1455
+ // de fichero a módulo, el filtro dejó de estar donde se emitía este motivo, y
1456
+ // sin esta rama los dos silencios se contaban igual — la tasa perdía la
1457
+ // única distinción que sabe explicar POR QUÉ calló.
1458
+ return callar(algunoYaAvisado ? "ya-avisado" : "sin-lineas");
1304
1459
  }
1305
1460
  // EL TECHO, sobre el total y no por fichero: una edición que toca cuatro
1306
1461
  // archivos servía cuatro bloques enteros, y el coste del canal se multiplicaba
package/dist/index.js CHANGED
@@ -22,7 +22,8 @@ import { hookStatus, installHook, uninstallHook } from "./hook.js";
22
22
  import { importHistory, planDeImport } from "./import.js";
23
23
  import { registerAgents } from "./init.js";
24
24
  import { contextHookInstalled, installContextHook, installSettingsHook, printContext, settingsHookInstalled, uninstallContextHook, uninstallSettingsHook, } from "./context.js";
25
- import { IMPACT_HOOK, informeDeSilencio, printImpact, silenciosAcumulados, warmImpactCache, } from "./impact.js";
25
+ import { IMPACT_HOOK, informeDeSilencio, printImpact, resolutorDeModulos, silenciosAcumulados, tasaDeSilencio, warmImpactCache, } from "./impact.js";
26
+ import { diagnosticoDeFriccion, dirDeTranscripciones, procesarFriccion, rankingDeFriccion, } from "./friction.js";
26
27
  import { login } from "./login.js";
27
28
  import { confirmar, hayTerminal, preguntar } from "./pregunta.js";
28
29
  import { argumentosDeScan, contarCommits, correrScan, informeParaInit, informeDeRepo, } from "./scan.js";
@@ -66,6 +67,9 @@ Usage:
66
67
  blocks an edit; silent when there is nothing to say
67
68
  changebook silence [dir] How often that hook stays quiet, with both raw
68
69
  numbers. Healthy is 85-95%: below 80% it is noise
70
+ changebook friction [dir] Where the agent's work gets redone in this repo,
71
+ read from the local Claude Code transcripts. Says
72
+ MUERTO if the repo has edits and it read nothing
69
73
  changebook scan [dir] [--json|--card|--badge]
70
74
  Coupling report for any repo, from its git history
71
75
  alone. No account, no network, writes nothing — run it
@@ -333,6 +337,32 @@ async function main() {
333
337
  await printImpact(new Supabase());
334
338
  return;
335
339
  }
340
+ case "friction": {
341
+ // Sin credenciales y sin red, como `silence`: la fricción se calcula en
342
+ // local y esta orden sólo la enseña. Lo que sube es cosa del `--warm`.
343
+ const dir = process.argv[3] ?? process.cwd();
344
+ // El MISMO resolutor que usa `--warm`. Esta orden también ENCOLA (es un
345
+ // efecto de `procesarFriccion`) y además avanza la marca de agua, así que
346
+ // si resolviera distinto envenenaría lo que se sube: pasó: 1.505 filas en
347
+ // prod sin módulo, y el bloque del brief filtra por módulo no nulo.
348
+ const { moduloDe, hayCache } = await resolutorDeModulos(dir);
349
+ const marca = await procesarFriccion(dir, {
350
+ ahoraMs: Date.now(),
351
+ dirTranscripciones: dirDeTranscripciones(dir),
352
+ moduloDe,
353
+ });
354
+ if (!hayCache) {
355
+ console.log("⚠ Sin caché del atlas: los eventos se encolan SIN módulo. Edita algo y vuelve a probar.");
356
+ }
357
+ // El denominador del canal de vitalidad: cuántas ediciones vio el hook.
358
+ // Si hubo ediciones y el lector leyó cero bytes, está muerto — y eso es
359
+ // lo único que un repo tranquilo no puede parecer.
360
+ const { total: edicionesVistas } = tasaDeSilencio(await silenciosAcumulados(dir));
361
+ console.log(diagnosticoDeFriccion(marca, edicionesVistas));
362
+ console.log("");
363
+ console.log(rankingDeFriccion(dir));
364
+ return;
365
+ }
336
366
  case "silence": {
337
367
  // Sin credenciales y sin red: el contador es local por diseño (ver
338
368
  // tallyPath). Se lee del repo, no del servidor, y por eso contesta el