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/import.js CHANGED
@@ -27,10 +27,35 @@ const SKIP_FILE_PATTERNS = [
27
27
  /\.min\.(js|css)$/,
28
28
  /\.(png|jpe?g|gif|webp|ico|svg|woff2?|ttf|otf|eot|mp4|mov|pdf|zip)$/i,
29
29
  ];
30
+ /** Tope duro de commits por importación. Vive aquí para que la estimación y el
31
+ * trabajo real no puedan discrepar: son el mismo número. */
32
+ export const TECHO_COMMITS = 100;
33
+ /** Lo que se importa cuando nadie pide un número. */
34
+ export const COMMITS_POR_DEFECTO = 25;
35
+ /**
36
+ * Cuántos commits se van a importar de verdad, y qué cuesta.
37
+ *
38
+ * SEPARADO DEL TRABAJO A PROPÓSITO, y es lo que hace honesta la pregunta de
39
+ * B8. La ficha habla de «un repo con 200 commits», pero `importHistory` topa en
40
+ * 100: enseñar «200 créditos» y gastar 100 sería una estimación falsa, y
41
+ * enseñar 200 y gastar 200 sería mentir sobre el tope. Este es el ÚNICO sitio
42
+ * donde se decide el número, y `importHistory` usa el mismo.
43
+ *
44
+ * El crédito es la unidad del usuario y la equivalencia está fijada en el
45
+ * servidor: «every row costs exactly 1 credit, so a row count equals the credit
46
+ * sum» (`ensureUnderMonthlyCredits`). Un commit analizado es una fila.
47
+ */
48
+ export function planDeImport(commitsEnElRepo, pedidos) {
49
+ const quiere = Math.max(1, Math.min(pedidos ?? COMMITS_POR_DEFECTO, TECHO_COMMITS));
50
+ const aImportar = Math.max(0, Math.min(quiere, commitsEnElRepo));
51
+ return { aImportar, creditos: aImportar };
52
+ }
30
53
  export async function importHistory(db, options = {}) {
31
54
  const cwd = path.resolve(options.dir ?? process.cwd());
32
55
  const projectName = projectNameFor(cwd);
33
- const count = Math.max(1, Math.min(options.commits ?? 25, 100));
56
+ // El MISMO número que vio el usuario en la estimación. Si esta línea y
57
+ // `planDeImport` se separan, la cifra que confirmó deja de ser la que se gasta.
58
+ const count = planDeImport(Number.MAX_SAFE_INTEGER, options.commits).aImportar;
34
59
  // A previous run may have been interrupted after submitting: the Anthropic
35
60
  // batch keeps running server-side, so finish those before spending more.
36
61
  await resumePendingBatches(db);
package/dist/index.js CHANGED
@@ -19,11 +19,13 @@ import { clearCredentials, credentialsPath } from "./credentials.js";
19
19
  import { recordFeed } from "./feed.js";
20
20
  import { runGuard } from "./guard.js";
21
21
  import { hookStatus, installHook, uninstallHook } from "./hook.js";
22
- import { importHistory } from "./import.js";
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, printImpact, warmImpactCache } from "./impact.js";
25
+ import { IMPACT_HOOK, informeDeSilencio, printImpact, silenciosAcumulados, warmImpactCache, } from "./impact.js";
26
26
  import { login } from "./login.js";
27
+ import { confirmar, hayTerminal, preguntar } from "./pregunta.js";
28
+ import { argumentosDeScan, contarCommits, correrScan, informeParaInit, informeDeRepo, } from "./scan.js";
27
29
  import { AUTH_HELP, Supabase } from "./supabase.js";
28
30
  import { syncContextFiles } from "./sync.js";
29
31
  import { registerTools } from "./tools.js";
@@ -47,7 +49,10 @@ Usage:
47
49
  (pre-commit signal guard)
48
50
  changebook guard [dir] Check staged files against open atlas alerts
49
51
  (what the pre-commit hook runs; exit 3 = block)
50
- changebook sync [dir] Refresh the product map inside CLAUDE.md/AGENTS.md
52
+ changebook sync [dir] [--yes]
53
+ Refresh the product map inside CLAUDE.md/AGENTS.md.
54
+ Shows what changes and asks first; --yes skips the
55
+ question (as does having no terminal)
51
56
  changebook context [dir] Print the fresh atlas brief as a Claude Code
52
57
  SessionStart hook payload (used by the push hook)
53
58
  changebook hook-context install|uninstall|status [dir]
@@ -59,6 +64,17 @@ Usage:
59
64
  Tell the agent who depends on a file BEFORE it edits it,
60
65
  via a PreToolUse hook in .claude/settings.json. Never
61
66
  blocks an edit; silent when there is nothing to say
67
+ changebook silence [dir] How often that hook stays quiet, with both raw
68
+ numbers. Healthy is 85-95%: below 80% it is noise
69
+ changebook scan [dir] [--json|--card|--badge]
70
+ Coupling report for any repo, from its git history
71
+ alone. No account, no network, writes nothing — run it
72
+ on a repo you just cloned. --json for machines,
73
+ --card for a shareable SVG (redirect it to a file),
74
+ --badge to publish the numbers and print the README
75
+ snippet (needs an account; --badge --off turns it off).
76
+ The badge exposes FOUR NUMBERS and nothing else — not
77
+ your code, modules or change summaries
62
78
  changebook init [dir] login + register MCP in every agent found + hook + sync
63
79
  changebook open Open the web atlas in the browser
64
80
  changebook serve Run the MCP server on stdio (default with no arguments)
@@ -121,6 +137,50 @@ function parseImportArgs(args) {
121
137
  }
122
138
  return { dir, commits };
123
139
  }
140
+ /**
141
+ * B7 · el confirmador de `sync` e `init`. `undefined` significa «escribe sin
142
+ * preguntar», que es lo que hace el resto del producto.
143
+ *
144
+ * SIN TERMINAL SE APLICA, y aquí sí, al revés que al importar. El criterio no es
145
+ * «preguntar siempre que se pueda»: es qué pasa si nadie contesta. Importar
146
+ * gasta créditos, así que callar tiene que significar NO. Esto reescribe un
147
+ * bloque delimitado del propio repo, sin coste y sin salir de la máquina, así
148
+ * que callar significa SÍ — bloquear un `sync` en CI rompería el refresco del
149
+ * mapa a cambio de nada.
150
+ */
151
+ function confirmadorDeEscritura(yes) {
152
+ if (yes || !hayTerminal())
153
+ return undefined;
154
+ return async (cambio) => {
155
+ console.error(`\n${cambio.resumen}`);
156
+ for (;;) {
157
+ const r = (await preguntar("[Enter] aplicar [s] saltar [d] ver diff completo"))
158
+ .trim()
159
+ .toLowerCase();
160
+ if (r === "d") {
161
+ // El diff entero solo para quien lo pide: el objetivo de B7 no es
162
+ // volcar un diff, es que se vea que el fichero de uno se respeta.
163
+ console.error(`\n${diffCompleto(cambio.previo, cambio.siguiente)}\n`);
164
+ continue;
165
+ }
166
+ if (r === "s")
167
+ return false;
168
+ if (r === "" || r === "a")
169
+ return true;
170
+ }
171
+ };
172
+ }
173
+ /** Diff de líneas, sin dependencias: marca lo que sale y lo que entra. */
174
+ function diffCompleto(previo, siguiente) {
175
+ const antes = (previo ?? "").split("\n");
176
+ const despues = siguiente.split("\n");
177
+ const vivas = new Set(despues);
178
+ const comunes = new Set(antes);
179
+ return [
180
+ ...antes.filter((l) => !vivas.has(l)).map((l) => `- ${l}`),
181
+ ...despues.filter((l) => !comunes.has(l)).map((l) => `+ ${l}`),
182
+ ].join("\n");
183
+ }
124
184
  async function main() {
125
185
  const [command, arg] = process.argv.slice(2);
126
186
  switch (command) {
@@ -222,7 +282,11 @@ async function main() {
222
282
  case "sync": {
223
283
  const db = new Supabase();
224
284
  requireCredentials(db);
225
- await syncContextFiles(db, arg ?? process.cwd());
285
+ const resto = process.argv.slice(3);
286
+ const dir = resto.find((a) => !a.startsWith("--")) ?? process.cwd();
287
+ await syncContextFiles(db, dir, {
288
+ confirmar: confirmadorDeEscritura(resto.includes("--yes")),
289
+ });
226
290
  return;
227
291
  }
228
292
  case "context": {
@@ -269,6 +333,55 @@ async function main() {
269
333
  await printImpact(new Supabase());
270
334
  return;
271
335
  }
336
+ case "silence": {
337
+ // Sin credenciales y sin red: el contador es local por diseño (ver
338
+ // tallyPath). Se lee del repo, no del servidor, y por eso contesta el
339
+ // mismo día en que se instala en vez de dentro de dos.
340
+ const dir = process.argv[3] ?? process.cwd();
341
+ console.log(informeDeSilencio(await silenciosAcumulados(dir)));
342
+ return;
343
+ }
344
+ case "scan": {
345
+ // Como `silence`: sin credenciales, sin red, sin escribir. Todo lo que
346
+ // dice sale del `git log` del directorio que le pasen — por eso se puede
347
+ // correr sobre el repo de otro sin pedirle nada a nadie, que es el punto
348
+ // entero de B2.
349
+ // El parseo vive en scan.ts para que se pueda probar sin construir el
350
+ // binario: ver `argumentosDeScan` y el porqué escrito allí.
351
+ const { dir, json, card, badge, off } = argumentosDeScan(process.argv.slice(3));
352
+ if (badge) {
353
+ // La única forma de `scan` que necesita cuenta y red. El cálculo es el
354
+ // mismo; lo que cambia es que además se publica para que el endpoint
355
+ // pueda servir la imagen.
356
+ const { apagarBadge, publicarBadge, LO_QUE_SE_PUBLICA } = await import("./badge.js");
357
+ const db = new Supabase();
358
+ const r = off
359
+ ? await apagarBadge(db, dir)
360
+ : await publicarBadge(db, dir, await informeDeRepo(dir));
361
+ if (!r.ok) {
362
+ console.error(r.motivo);
363
+ process.exitCode = 1;
364
+ return;
365
+ }
366
+ if (r.apagado) {
367
+ console.error("✓ Badge apagado. La imagen deja de servirse y sus números se han\n" +
368
+ " borrado. Tu atlas no se ha tocado.");
369
+ return;
370
+ }
371
+ console.log(r.snippet);
372
+ // QUÉ se ha hecho público, la primera vez y con todas las letras. Lo
373
+ // que se expone son cuatro números y nada más, pero eso lo tiene que
374
+ // decir el producto, no descubrirlo el usuario.
375
+ console.error(r.nuevo ? `\n${LO_QUE_SE_PUBLICA}\n` : "");
376
+ console.error("✓ Números publicados. Pega esa línea en tu README.\n" +
377
+ " El badge dice qué parte de tus ficheros concentra la mitad de los\n" +
378
+ " cambios — una propiedad de tu repo, no de cuánto llevas usando\n" +
379
+ " ChangeBook, así que significa lo mismo hoy que dentro de tres meses.");
380
+ return;
381
+ }
382
+ console.log(await correrScan(dir, { json, card }));
383
+ return;
384
+ }
272
385
  case "hook-impact": {
273
386
  const dir = process.argv[4] ?? process.cwd();
274
387
  if (arg === "install") {
@@ -307,7 +420,12 @@ async function main() {
307
420
  catch (error) {
308
421
  console.error(error instanceof Error ? error.message : String(error));
309
422
  }
310
- await syncContextFiles(db, dir);
423
+ // B7 también aquí, y aquí es donde más importa: `init` es la primera vez
424
+ // que el producto escribe en un fichero del usuario, y la confianza se
425
+ // gana o se pierde en ese momento.
426
+ await syncContextFiles(db, dir, {
427
+ confirmar: confirmadorDeEscritura(process.argv.includes("--yes")),
428
+ });
311
429
  // Ofrecer el push, NUNCA instalarlo en silencio: .claude/settings.json se
312
430
  // suele commitear y compartir, y meter un hook en el arranque de sesiones
313
431
  // ajenas sin permiso explícito no se hace. Se ofrece el comando; correrlo
@@ -325,6 +443,56 @@ async function main() {
325
443
  console.error("\nOptional: before every edit, tell the agent who depends on the file " +
326
444
  "it is about to touch:\n changebook hook-impact install");
327
445
  }
446
+ // B8 · el historial pasado, ofrecido en el momento de instalar.
447
+ //
448
+ // El desfase de valor no se arregla solo con el informe local: el atlas
449
+ // sigue vacío hasta que haya commits ANALIZADOS. Importar el pasado lo
450
+ // llena de golpe — y por eso hasta hoy era una opción avanzada que nadie
451
+ // encontraba.
452
+ //
453
+ // PERO ESTO GASTA CRÉDITOS DEL USUARIO, así que la cifra va delante y en
454
+ // su unidad. `planDeImport` es el único sitio donde se decide cuántos
455
+ // commits entran, y `importHistory` usa esa misma función: la cifra que
456
+ // se confirma no puede separarse de la que se gasta.
457
+ //
458
+ // Sin terminal NO se importa y no se pregunta — un `init` dentro de un CI
459
+ // o un Dockerfile no tiene a nadie delante, y asumir el sí ahí convierte
460
+ // una instalación automatizada en un cargo que nadie autorizó.
461
+ const plan = planDeImport(await contarCommits(dir));
462
+ if (plan.aImportar > 0) {
463
+ if (!hayTerminal()) {
464
+ console.error(`\nHay ${plan.aImportar} commits que se pueden importar para llenar el atlas ` +
465
+ `de golpe (${plan.creditos} créditos). Sin terminal no se hace solo:\n` +
466
+ ` changebook import`);
467
+ }
468
+ else if (await confirmar(`\n¿Importar los últimos ${plan.aImportar} commits para llenar el atlas ahora?\n` +
469
+ `Cuesta ${plan.creditos} créditos de tu plan de este mes. [S/n]`)) {
470
+ try {
471
+ await importHistory(db, { dir, commits: plan.aImportar });
472
+ }
473
+ catch (error) {
474
+ // Un import fallido no invalida la instalación: el MCP, los hooks y
475
+ // el sync ya están puestos. Se dice y se sigue.
476
+ console.error(`\nLa importación no pudo terminar: ${error instanceof Error ? error.message : String(error)}\nEl resto de la instalación está hecha. Reintenta con: changebook import`);
477
+ }
478
+ }
479
+ else {
480
+ console.error(`\nSin importar. Cuando quieras: changebook import --commits ${plan.aImportar}`);
481
+ }
482
+ }
483
+ // B8 · «instalar es descubrir, no esperar». El producto tiene un desfase
484
+ // de valor estructural: instalas hoy y el atlas empieza a servir mañana,
485
+ // cuando haya commits analizados. Y el hook de impacto está diseñado para
486
+ // callarse, así que en la primera hora no se ve NADA — un producto que se
487
+ // instala en silencio y calla se desinstala en silencio.
488
+ //
489
+ // El informe de acoplamiento tapa ese hueco sin esperar a nada: sale
490
+ // entero del `git log` que ya está en el disco, no cuesta un crédito y no
491
+ // llama a ningún modelo. Va por stdout —no por stderr como el resto del
492
+ // progreso— porque es CONTENIDO, no traza: así se puede canalizar.
493
+ const informe = await informeParaInit(dir);
494
+ if (informe)
495
+ console.log(`\n${informe}`);
328
496
  console.error(`✓ Ready. Ask your agent about the atlas, or open ${atlasWebUrl()}`);
329
497
  return;
330
498
  }
@@ -0,0 +1,59 @@
1
+ // `readline/promises`, no `readline` a secas: el `question` del segundo lleva
2
+ // callback y devuelve `void`, así que `await` sobre él espera a nada y sigue de
3
+ // largo. Compila, corre, y la pregunta se contesta sola.
4
+ import { createInterface } from 'node:readline/promises';
5
+ // ── Preguntar antes de gastar ────────────────────────────────────────────────
6
+ //
7
+ // El CLI no preguntaba nada hasta ahora, y hay un motivo para que la primera
8
+ // pregunta sea justo esta: importar el historial GASTA CRÉDITOS del usuario.
9
+ //
10
+ // LA REGLA QUE GOBIERNA ESTE FICHERO: sin terminal no se gasta. Un `init` dentro
11
+ // de un script, un CI o un Dockerfile no tiene a nadie delante a quien
12
+ // preguntar, y en esa situación la respuesta segura no es «asumo que sí» — es no
13
+ // hacerlo y decir cómo pedirlo explícitamente. Asumir el sí convierte un `init`
14
+ // automatizado en un cargo recurrente que nadie autorizó.
15
+ /** ¿Hay una persona delante? Las dos puntas, porque preguntar necesita las dos. */
16
+ export function hayTerminal(entrada = process.stdin, salida = process.stderr) {
17
+ return Boolean(entrada.isTTY && salida.isTTY);
18
+ }
19
+ /**
20
+ * Interpreta la respuesta. Separado de la lectura para poder probarlo sin
21
+ * terminal: la parte que decide es esta, no la que lee bytes.
22
+ *
23
+ * Solo un «no» explícito declina. Enter acepta —la ficha de B8 pide que importar
24
+ * sea el DEFECTO— pero la cifra va delante, en la pregunta, no en la letra
25
+ * pequeña.
26
+ */
27
+ export function esUnSi(respuesta) {
28
+ const r = respuesta.trim().toLowerCase();
29
+ if (r === '')
30
+ return true;
31
+ return r === 's' || r === 'si' || r === 'sí' || r === 'y' || r === 'yes';
32
+ }
33
+ /**
34
+ * Pregunta y espera. Sin terminal devuelve `false` SIN preguntar y sin bloquear:
35
+ * un proceso que se queda esperando una respuesta que nunca llega es peor que
36
+ * uno que no hace el trabajo.
37
+ */
38
+ export async function confirmar(pregunta) {
39
+ if (!hayTerminal())
40
+ return false;
41
+ return esUnSi(await preguntar(pregunta));
42
+ }
43
+ /**
44
+ * Una respuesta cruda, para las preguntas de más de dos salidas (B7: aplicar,
45
+ * saltar, ver el diff). Sin terminal devuelve cadena vacía SIN bloquear — quien
46
+ * llame decide qué significa eso, que no es lo mismo en todas partes.
47
+ */
48
+ export async function preguntar(pregunta) {
49
+ if (!hayTerminal())
50
+ return '';
51
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
52
+ try {
53
+ return await rl.question(`${pregunta} `);
54
+ }
55
+ finally {
56
+ rl.close();
57
+ }
58
+ }
59
+ //# sourceMappingURL=pregunta.js.map
package/dist/reglas.js ADDED
@@ -0,0 +1,299 @@
1
+ /**
2
+ * B9 · Lo que ya rompió aquí se convierte en regla.
3
+ *
4
+ * LA DISCIPLINA, que es lo que separa esto de un linter: **solo entran reglas
5
+ * nacidas de un fallo REAL de este repo, con su hash**. Un catálogo de reglas
6
+ * genéricas convierte el producto en un linter, y los linters son gratis. Esto
7
+ * no envía una librería de reglas: envía el mecanismo que convierte cada
8
+ * cicatriz en una regla que caza el mismo patrón en cualquier fichero.
9
+ *
10
+ * POR QUÉ ESTAS TRES Y NO LAS DE LA FICHA. La ficha eligió tres fallos de la web
11
+ * (ruta huérfana, estado sucio antes de navegar fuera, test de contrato que
12
+ * nunca se pone rojo). Los tres son reales, pero para cazarlos en el repo de
13
+ * OTRO hay que entender su framework —qué es una ruta, qué es un manejador— o
14
+ * ejecutar su suite de tests. O sea que solo servirían a repos como éste.
15
+ *
16
+ * Las de aquí salen de fallos del 25/07 y del 02/08 y no necesitan entender
17
+ * nada: son sobre la BASE DE DATOS y el DESPLIEGUE, que funcionan igual para
18
+ * todo el mundo. Cada una se apaga sola si el repo no tiene esa forma
19
+ * (`aplica`), en vez de opinar sobre un proyecto que no la tiene.
20
+ *
21
+ * SE ESCRIBIERON TRES Y SE ENVÍAN DOS. La tercera se midió sobre este repo,
22
+ * salió con 6 hallazgos y los 6 falsos, y no se sirve — ver R2 más abajo, que se
23
+ * queda escrita como aviso a quien la reinvente. Medir antes de servir es el
24
+ * criterio 2 de la ficha, y es lo único que separa esto de un linter ruidoso.
25
+ *
26
+ * TODAS FALLAN CERRADAS HACIA EL SILENCIO. Ante la duda no hay hallazgo: un
27
+ * aviso falso en un pre-commit se paga con la desinstalación del hook, y eso
28
+ * cuesta más que el defecto que se dejó pasar.
29
+ */
30
+ import * as fs from 'node:fs';
31
+ import * as path from 'node:path';
32
+ // ── Utilidades compartidas ──────────────────────────────────────────────────
33
+ /** Quita comentarios de línea `--` y bloques de SQL. Igual que en los tests. */
34
+ export function sqlSinComentarios(src) {
35
+ return src
36
+ .replace(/\/\*[\s\S]*?\*\//g, '')
37
+ .split('\n')
38
+ .filter((l) => !l.trim().startsWith('--'))
39
+ .join('\n');
40
+ }
41
+ const esMigracion = (f) => f.endsWith('.sql') && /(^|\/)migrations\//.test(f);
42
+ /**
43
+ * Quita los comentarios de un fichero, con la gramática de su extensión.
44
+ *
45
+ * POR QUÉ (invariante 13, y una tercera vez el mismo día). Una regla que lee
46
+ * fuente y afirma sobre él tiene que mirar el CÓDIGO, no la prosa. Medido: al
47
+ * arreglar uno de los hallazgos escribí un comentario explicando que la cascada
48
+ * ya no vivía en `resolve_agent_task`… y esa mención hizo que la regla siguiera
49
+ * avisando del mismo fichero. El comentario que explica un arreglo es
50
+ * exactamente lo que lo camufla.
51
+ *
52
+ * Extensión desconocida: se devuelve tal cual. Perder un comentario mal quitado
53
+ * es peor que dejarlo — el lado seguro aquí es el falso positivo ruidoso, no el
54
+ * silencio.
55
+ */
56
+ export function sinComentariosPorExtension(rel, src) {
57
+ const ext = /\.([a-z0-9]+)$/i.exec(rel)?.[1]?.toLowerCase() ?? '';
58
+ const abre = {
59
+ ts: '//', tsx: '//', js: '//', jsx: '//', mjs: '//', cjs: '//',
60
+ sql: '--', yml: '#', yaml: '#', toml: '#', py: '#', rb: '#', sh: '#',
61
+ };
62
+ const marca = abre[ext];
63
+ if (!marca)
64
+ return src;
65
+ const conBloques = marca === '//' || marca === '--'
66
+ ? src.replace(/\/\*[\s\S]*?\*\//g, '')
67
+ : src;
68
+ return conBloques
69
+ .split('\n')
70
+ .filter((l) => !l.trim().startsWith(marca))
71
+ .join('\n');
72
+ }
73
+ // ── R1 · Un contrato mirando una versión muerta ─────────────────────────────
74
+ /**
75
+ * `sql-version-vieja`
76
+ *
77
+ * DE DÓNDE SALE. `atlas_brief` está definida en tres migraciones y
78
+ * `admin_metrics` en CINCO: cada versión es un `create or replace` en un fichero
79
+ * nuevo, y la que manda es la última. Dos tests fijaban el fichero POR NOMBRE, o
80
+ * sea que vigilaban una versión que ya nadie ejecuta y seguían en verde con el
81
+ * defecto dentro. Peor: creyendo que ese fichero era LA definición, reaplicarlo
82
+ * a producción habría degradado la función dos versiones.
83
+ *
84
+ * QUÉ CAZA. Un fichero que NO es una migración y nombra el fichero de una
85
+ * migración que redefine una función SQL, existiendo otra posterior que la
86
+ * redefine también.
87
+ *
88
+ * POR QUÉ GENERALIZA. Solo mira nombres de fichero y `create or replace
89
+ * function`. No sabe nada de este proyecto ni de ningún framework.
90
+ *
91
+ * LO QUE MIDIÓ, sobre este repo (612 ficheros), y se publica con la regla como
92
+ * pide el criterio 2 de la ficha:
93
+ *
94
+ * 1ª pasada 9 hallazgos · 3 falsos (uno de un doc que NARRA historia, dos de
95
+ * ficheros que citaban la migración por su tabla). Afinada con dos
96
+ * condiciones: se saltan los `.md` y se exige que el fichero
97
+ * NOMBRE la función.
98
+ * 2ª pasada 6 hallazgos, los 6 CIERTOS, leídos uno a uno. Uno de ellos era
99
+ * de esa misma mañana: `atlas_precision` había cambiado y su test
100
+ * seguía verificando la regla vieja.
101
+ * arreglados 5 (el sexto se arregló de camino). Quedan 2.
102
+ *
103
+ * SU LÍMITE, dicho en vez de escondido: los 2 que quedan son ficheros que leen
104
+ * la migración vieja POR OTRA COSA —las columnas que creó, que las versiones
105
+ * posteriores no tocan— y que además nombran la función en otra aserción. Un
106
+ * texto no puede distinguir eso, y estrechar más la regla la dejaría ciega a
107
+ * los casos ciertos. Se prefiere que quien lo lea mire, a no avisar.
108
+ */
109
+ export const SQL_VERSION_VIEJA = {
110
+ id: 'sql-version-vieja',
111
+ hash: 'dc075b6',
112
+ cicatriz: 'Dos tests afirmaban sobre una migración que ya no era la versión en vigor',
113
+ aplica: (repo) => repo.ficheros.some(esMigracion),
114
+ correr(repo) {
115
+ // función → migraciones que la definen, en orden de nombre (= de aplicación).
116
+ const porFuncion = new Map();
117
+ for (const f of repo.ficheros.filter(esMigracion).sort()) {
118
+ const sql = repo.leer(f);
119
+ if (!sql)
120
+ continue;
121
+ const limpio = sqlSinComentarios(sql);
122
+ for (const m of limpio.matchAll(/create\s+or\s+replace\s+function\s+(?:[a-z_]+\.)?([a-z0-9_]+)\s*\(/gi)) {
123
+ const fn = m[1].toLowerCase();
124
+ porFuncion.set(fn, [...(porFuncion.get(fn) ?? []), f]);
125
+ }
126
+ }
127
+ // Solo las redefinidas: una función con UNA definición no tiene versión
128
+ // vieja que citar, y avisar ahí sería ruido sobre el caso normal. Lo hace
129
+ // el `slice(0, -1)` —sobre una lista de uno da vacío— y no un guard aparte:
130
+ // el guard que había estuvo aquí hasta que una mutación lo quitó y NADA se
131
+ // puso rojo. Era código muerto con pinta de protección.
132
+ const caducadas = new Map(); // basename viejo → función
133
+ for (const [fn, ficheros] of porFuncion) {
134
+ for (const viejo of ficheros.slice(0, -1)) {
135
+ caducadas.set(path.basename(viejo), fn);
136
+ }
137
+ }
138
+ if (caducadas.size === 0)
139
+ return [];
140
+ const fuera = [];
141
+ for (const f of repo.ficheros) {
142
+ if (esMigracion(f))
143
+ continue; // una migración puede citar a otra: es su historia
144
+ // LA DOCUMENTACIÓN NARRA, no afirma. Un doc que cuenta lo que pasó el 12
145
+ // de julio TIENE que citar el fichero de aquel día; exigirle el último
146
+ // sería pedirle que reescriba la historia. Medido: era 1 de los 9
147
+ // hallazgos de la primera pasada, y el único de su clase.
148
+ if (/\.(md|mdx|txt)$/i.test(f))
149
+ continue;
150
+ const crudo = repo.leer(f);
151
+ if (!crudo)
152
+ continue;
153
+ const src = sinComentariosPorExtension(f, crudo);
154
+ for (const [viejo, fn] of caducadas) {
155
+ if (!src.includes(viejo))
156
+ continue;
157
+ // Y CITAR NO ES AFIRMAR. Un test puede leer esa migración por la tabla
158
+ // que crea, por su trigger o por un grant, sin decir una palabra de la
159
+ // función redefinida. Sin esta condición se avisa de un fichero que no
160
+ // tiene nada que ver con lo que caducó — medido también en la primera
161
+ // pasada. Se exige que el fichero NOMBRE la función.
162
+ if (!src.includes(fn))
163
+ continue;
164
+ fuera.push({
165
+ regla: this.id,
166
+ hash: this.hash,
167
+ fichero: f,
168
+ que: `cita la migración ${viejo}, que ya no es la versión en vigor de ` +
169
+ `${fn}(). Lo que afirme sobre ella no vigila lo que corre en producción.`,
170
+ });
171
+ }
172
+ }
173
+ return fuera;
174
+ },
175
+ };
176
+ // ── R2 · MEDIDA Y DESCARTADA ────────────────────────────────────────────────
177
+ //
178
+ // La cicatriz es real y cara: `regression_alerts` tiene el `update` acotado POR
179
+ // COLUMNA a propósito, el 25/07 se añadió `resolution` sin meterla en el grant y
180
+ // los TRES caminos que la escribían empezaron a fallar con 42501 **en silencio**
181
+ // (`atlas_resolve_alert` devolvía «cerré 0» como si no hubiera nada que cerrar).
182
+ // Volvió a asomar el 02/08 con `resolution_by`.
183
+ //
184
+ // La regla obvia —«columna añadida a una tabla con grant por columna y ausente
185
+ // de él»— SE MIDIÓ Y NO SIRVE: 6 hallazgos sobre este repo, **los 6 falsos**.
186
+ // `evidence_symbol`, `evidence_scope`, `evidence_line`, `chars_served`,
187
+ // `latency_ms` y `advice_hash` se escriben todas en el INSERT, y un grant de
188
+ // UPDATE por columna no tiene nada que decir sobre ellas. La regla confunde
189
+ // «existe la columna» con «alguien la actualiza», y esas dos cosas no se
190
+ // distinguen mirando solo el SQL.
191
+ //
192
+ // Se deja escrito en vez de borrado para que nadie la reinvente creyendo que es
193
+ // evidente: para que valiera habría que saber qué columnas viajan en un UPDATE
194
+ // del cliente, y eso vive en el código, no en el esquema. Criterio 2 de la
195
+ // ficha B9: por encima de ~15% de falsos positivos no se sirve. Aquí es 100%.
196
+ // ── R3 · Una función desplegada sin declarar ────────────────────────────────
197
+ /**
198
+ * `funcion-sin-declarar`
199
+ *
200
+ * DE DÓNDE SALE. El endpoint del badge se escribió, se desplegó y no fallaba
201
+ * nada… y devolvía 401 a todo el que mirara un README, porque sin bloque en
202
+ * `config.toml` una función hereda `verify_jwt = true`. Al poner el contrato
203
+ * salieron CINCO más sin declarar: ahí el valor por defecto era el correcto,
204
+ * pero la garantía era invisible.
205
+ *
206
+ * QUÉ CAZA. Un directorio de función sin su `[functions.<nombre>]`.
207
+ *
208
+ * POR QUÉ GENERALIZA (dentro de su forma). No sabe nada de este proyecto: se
209
+ * activa solo si el repo tiene `config.toml` y un directorio de funciones. Un
210
+ * repo sin eso no oye ni una palabra de esta regla.
211
+ */
212
+ export const FUNCION_SIN_DECLARAR = {
213
+ id: 'funcion-sin-declarar',
214
+ hash: '4444c1b',
215
+ cicatriz: 'Una edge function sin bloque en config.toml: pública en el código y 401 para todo el mundo',
216
+ aplica: (repo) => repo.ficheros.some((f) => f.endsWith('supabase/config.toml')),
217
+ correr(repo) {
218
+ const config = repo.ficheros.find((f) => f.endsWith('supabase/config.toml'));
219
+ if (!config)
220
+ return [];
221
+ const toml = repo.leer(config);
222
+ // `null` es «no se pudo leer» y `''` es «está vacío», y NO son lo mismo: un
223
+ // config vacío no declara nada, así que todas las funciones están sin
224
+ // declarar. Con `if (!toml)` la regla se callaba justo en el repo donde más
225
+ // tendría que hablar — la misma confusión entre «no hay nada» y «no miré»
226
+ // que persigue todo este proyecto. Lo destapó una mutación.
227
+ if (toml === null)
228
+ return [];
229
+ const funciones = new Set();
230
+ for (const f of repo.ficheros) {
231
+ const m = /(?:^|\/)supabase\/functions\/([^/]+)\/index\.ts$/.exec(f);
232
+ // `_shared` no es una función: es la librería que todas importan.
233
+ if (m && !m[1].startsWith('_'))
234
+ funciones.add(m[1]);
235
+ }
236
+ return [...funciones]
237
+ .filter((fn) => !toml.includes(`[functions.${fn}]`))
238
+ .sort()
239
+ .map((fn) => ({
240
+ regla: this.id,
241
+ hash: this.hash,
242
+ fichero: `supabase/functions/${fn}/index.ts`,
243
+ que: `no tiene bloque [functions.${fn}] en config.toml. Hereda ` +
244
+ `verify_jwt = true: si es pública, devolverá 401 sin que falle nada.`,
245
+ }));
246
+ },
247
+ };
248
+ export const REGLAS = [SQL_VERSION_VIEJA, FUNCION_SIN_DECLARAR];
249
+ /** Corre las reglas que apliquen. Puro sobre `Repo` para poder medirlo sin disco. */
250
+ export function correrReglas(repo, reglas = REGLAS) {
251
+ const fuera = [];
252
+ for (const r of reglas) {
253
+ try {
254
+ if (!r.aplica(repo))
255
+ continue;
256
+ fuera.push(...r.correr(repo));
257
+ }
258
+ catch {
259
+ // Una regla que revienta se calla. Ninguna puede tumbar un commit.
260
+ }
261
+ }
262
+ return fuera;
263
+ }
264
+ /** Un `Repo` respaldado por el disco, con el contenido cacheado por fichero. */
265
+ export function repoEnDisco(dir, ficheros) {
266
+ const cache = new Map();
267
+ return {
268
+ ficheros,
269
+ leer(rel) {
270
+ if (cache.has(rel))
271
+ return cache.get(rel) ?? null;
272
+ let texto = null;
273
+ try {
274
+ texto = fs.readFileSync(path.join(dir, rel), 'utf8');
275
+ }
276
+ catch {
277
+ texto = null;
278
+ }
279
+ cache.set(rel, texto);
280
+ return texto;
281
+ },
282
+ };
283
+ }
284
+ /** El texto para el pre-commit. Vacío cuando no hay nada — silencio por defecto. */
285
+ export function informeDeReglas(hallazgos) {
286
+ if (hallazgos.length === 0)
287
+ return '';
288
+ const lineas = ['\n⚠ ChangeBook · patrones que ya rompieron aquí antes:'];
289
+ for (const h of hallazgos) {
290
+ lineas.push(` ${h.fichero}`);
291
+ lineas.push(` ${h.que}`);
292
+ // LA PROCEDENCIA VA EN EL AVISO, no en un documento. Sin el hash esto es una
293
+ // opinión; con él es «esto te pasó, y aquí está la vez que pasó».
294
+ lineas.push(` Nació de: ${h.hash} · ${h.regla}`);
295
+ }
296
+ lineas.push('');
297
+ return lineas.join('\n');
298
+ }
299
+ //# sourceMappingURL=reglas.js.map