changebook 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/aciertos.js +100 -0
- package/dist/aliasDeModulo.js +203 -0
- package/dist/badge.js +159 -0
- package/dist/context.js +7 -1
- package/dist/guard.js +96 -13
- package/dist/hookMudo.js +172 -0
- package/dist/impact.js +319 -25
- package/dist/import.js +26 -1
- package/dist/index.js +173 -5
- package/dist/pregunta.js +59 -0
- package/dist/reglas.js +299 -0
- package/dist/scan.js +427 -0
- package/dist/supabase.js +84 -1
- package/dist/sync.js +291 -24
- package/dist/tools.js +182 -114
- package/package.json +2 -2
- package/server.json +3 -3
package/dist/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
|
-
|
|
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]
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/pregunta.js
ADDED
|
@@ -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
|