changebook 0.6.0 → 0.7.1
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/analyze.js +40 -2
- package/dist/badge.js +159 -0
- package/dist/context.js +7 -1
- package/dist/git.js +42 -2
- 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 +215 -29
- package/dist/tools.js +182 -44
- package/package.json +2 -2
- package/server.json +3 -3
package/dist/aciertos.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import { gitPath } from './git.js';
|
|
3
|
+
/** Cuántos días atrás se mira. Más allá, la noticia ya no es noticia. */
|
|
4
|
+
export const VENTANA_DIAS = 14;
|
|
5
|
+
/**
|
|
6
|
+
* La línea, o cadena vacía.
|
|
7
|
+
*
|
|
8
|
+
* UNA SOLA LÍNEA Y UNA SOLA ALERTA. Con dos aciertos a la vez la tentación es
|
|
9
|
+
* listarlos, y una lista de autoelogio en cada commit es ruido con otro
|
|
10
|
+
* nombre. Se dice el más reciente y se callan los demás; ya se dirán cuando
|
|
11
|
+
* toque, o no se dirán, que tampoco pasa nada.
|
|
12
|
+
*/
|
|
13
|
+
export function lineaDeAcierto(candidatos, yaDichas, ahora = Date.now()) {
|
|
14
|
+
const frescos = candidatos
|
|
15
|
+
.filter((a) => a.id && !yaDichas.has(a.id))
|
|
16
|
+
.filter((a) => (a.resolution ?? '') === 'fixed')
|
|
17
|
+
.filter((a) => (a.plain ?? '').trim().length > 0)
|
|
18
|
+
.filter((a) => {
|
|
19
|
+
const t = Date.parse(a.resolved_at ?? '');
|
|
20
|
+
return Number.isFinite(t) && ahora - t <= VENTANA_DIAS * 86_400_000;
|
|
21
|
+
})
|
|
22
|
+
.sort((a, b) => Date.parse(b.resolved_at ?? '') - Date.parse(a.resolved_at ?? ''));
|
|
23
|
+
const a = frescos[0];
|
|
24
|
+
if (!a)
|
|
25
|
+
return null;
|
|
26
|
+
const modulo = (a.module ?? '').trim();
|
|
27
|
+
const donde = modulo ? ` sobre ${modulo}` : '';
|
|
28
|
+
// QUIÉN lo juzgó va en la frase, no en una nota al pie. «Lo confirmaste tú» y
|
|
29
|
+
// «lo dio por bueno el agente que lo arregló» no valen lo mismo, y el producto
|
|
30
|
+
// no puede cobrarse el segundo como si fuera el primero.
|
|
31
|
+
const quien = a.resolution_by === 'human'
|
|
32
|
+
? 'y lo confirmaste tú'
|
|
33
|
+
: a.resolution_by === 'grep'
|
|
34
|
+
? 'y lo confirmó una comprobación en tu código'
|
|
35
|
+
: 'y lo dio por bueno el agente que lo arregló';
|
|
36
|
+
return {
|
|
37
|
+
id: a.id,
|
|
38
|
+
texto: `\n✓ ChangeBook acertó: el aviso${donde} era real, ${quien}.\n «${recorta(a.plain ?? '')}»\n`,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/** El texto del aviso, acotado: una línea de commit no es un informe. */
|
|
42
|
+
function recorta(s, max = 110) {
|
|
43
|
+
const limpio = s.trim().replace(/\s+/g, ' ');
|
|
44
|
+
return limpio.length <= max ? limpio : `${limpio.slice(0, max - 1)}…`;
|
|
45
|
+
}
|
|
46
|
+
/** Las que ya se dijeron. Fichero propio, mismo sitio que el resto del estado. */
|
|
47
|
+
async function memoriaPath(dir) {
|
|
48
|
+
return gitPath(dir, 'changebook-aciertos.json').catch(() => null);
|
|
49
|
+
}
|
|
50
|
+
function leerDichas(file) {
|
|
51
|
+
if (!file)
|
|
52
|
+
return new Set();
|
|
53
|
+
try {
|
|
54
|
+
const v = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
55
|
+
return new Set(Array.isArray(v) ? v.filter((x) => typeof x === 'string') : []);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return new Set();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function guardarDichas(file, ids) {
|
|
62
|
+
if (!file)
|
|
63
|
+
return;
|
|
64
|
+
try {
|
|
65
|
+
// Se recorta a las últimas 200: es una memoria para no repetirse, no un
|
|
66
|
+
// registro. Un fichero que solo crece acaba siendo un fichero que nadie
|
|
67
|
+
// borra y que se lee entero en cada commit.
|
|
68
|
+
fs.writeFileSync(file, JSON.stringify([...ids].slice(-200)));
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// Sin sitio donde anotar se repetirá una vez. Es el lado seguro.
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** `CHANGEBOOK_ACIERTOS=off` lo apaga del todo. */
|
|
75
|
+
export function estaSilenciado(env = process.env) {
|
|
76
|
+
return (env.CHANGEBOOK_ACIERTOS ?? '').trim().toLowerCase() === 'off';
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* La línea para este repo, y la marca para no repetirla.
|
|
80
|
+
*
|
|
81
|
+
* Best-effort de principio a fin: corre dentro del pre-commit y no puede
|
|
82
|
+
* romperlo. Y si no hay nada que celebrar, silencio — que es el estado normal.
|
|
83
|
+
*/
|
|
84
|
+
export function acierto(candidatos, dir, env) {
|
|
85
|
+
if (estaSilenciado(env))
|
|
86
|
+
return Promise.resolve('');
|
|
87
|
+
return memoriaPath(dir)
|
|
88
|
+
.catch(() => null)
|
|
89
|
+
.then((file) => {
|
|
90
|
+
const dichas = leerDichas(file);
|
|
91
|
+
const linea = lineaDeAcierto(candidatos, dichas);
|
|
92
|
+
if (!linea)
|
|
93
|
+
return '';
|
|
94
|
+
dichas.add(linea.id);
|
|
95
|
+
guardarDichas(file, dichas);
|
|
96
|
+
return linea.texto;
|
|
97
|
+
})
|
|
98
|
+
.catch(() => '');
|
|
99
|
+
}
|
|
100
|
+
//# sourceMappingURL=aciertos.js.map
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// ── Alias de módulo, lado CLI ────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// ESPEJO de `fetchModuleAliases` / `canonicalizeModuleRows` en
|
|
4
|
+
// supabase/functions/_shared/analysis.ts. La paridad está testada en
|
|
5
|
+
// test/aliasCliParidad.test.ts, y hace falta: el atlas se alimenta y se lee por
|
|
6
|
+
// TRES superficies —las functions del servidor, el MCP hospedado y este CLI— y
|
|
7
|
+
// una fusión que solo resolviera en dos de ellas haría que el guardián avisara
|
|
8
|
+
// sobre un módulo que el mapa ya no muestra con ese nombre.
|
|
9
|
+
//
|
|
10
|
+
// No se puede importar el original: aquel es código Deno y esto es Node.
|
|
11
|
+
export const SIN_ALIAS = {
|
|
12
|
+
aliases: new Map(),
|
|
13
|
+
hermanas: new Map(),
|
|
14
|
+
};
|
|
15
|
+
/** El MISMO slug que usa el servidor (slugModule) y el mapa de la web. */
|
|
16
|
+
export function slugModule(s) {
|
|
17
|
+
return (s
|
|
18
|
+
.toLowerCase()
|
|
19
|
+
.normalize('NFD')
|
|
20
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
21
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
22
|
+
.replace(/^-|-$/g, '') || 'modulo');
|
|
23
|
+
}
|
|
24
|
+
/** Aplana las cadenas (A→B, B→C deja A en C) con tope y detección de ciclo. */
|
|
25
|
+
export function resolveAliasChains(directo) {
|
|
26
|
+
const out = new Map();
|
|
27
|
+
for (const [from, to] of directo) {
|
|
28
|
+
let actual = to;
|
|
29
|
+
const visto = new Set([from]);
|
|
30
|
+
for (let salto = 0; salto < 10; salto++) {
|
|
31
|
+
const siguiente = directo.get(slugModule(actual));
|
|
32
|
+
if (!siguiente || visto.has(slugModule(actual)))
|
|
33
|
+
break;
|
|
34
|
+
visto.add(slugModule(actual));
|
|
35
|
+
actual = siguiente;
|
|
36
|
+
}
|
|
37
|
+
if (slugModule(actual) !== from)
|
|
38
|
+
out.set(from, actual);
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* slug → etiqueta que lo representa. ESPEJO de `etiquetaPorSlug` en
|
|
44
|
+
* supabase/functions/mcp/scope.ts; la paridad la vigila
|
|
45
|
+
* test/paridadCoCambios.test.ts, porque el CLI y el hospedado calculan los
|
|
46
|
+
* mismos co-cambios y una discrepancia aquí los haría diferir en silencio.
|
|
47
|
+
*
|
|
48
|
+
* Existe porque `canonicalModule` NO cubre esta clase: hace
|
|
49
|
+
* `if (aliases.size === 0) return limpia`, así que con la tabla de alias vacía
|
|
50
|
+
* —0 filas en los tres proyectos— «Brief de orientación» y «Brief de
|
|
51
|
+
* orientacion» siguen siendo dos módulos distintos al agrupar.
|
|
52
|
+
*
|
|
53
|
+
* Desempate: la más frecuente; a igualdad, la más reciente. `labels` viene
|
|
54
|
+
* newest-first, así que la primera vista es la más reciente.
|
|
55
|
+
*/
|
|
56
|
+
export function etiquetaPorSlug(labels) {
|
|
57
|
+
const cuentas = new Map();
|
|
58
|
+
for (const raw of labels) {
|
|
59
|
+
const label = (raw ?? '').trim();
|
|
60
|
+
if (!label)
|
|
61
|
+
continue;
|
|
62
|
+
const slug = slugModule(label);
|
|
63
|
+
let porEtiqueta = cuentas.get(slug);
|
|
64
|
+
if (!porEtiqueta) {
|
|
65
|
+
porEtiqueta = new Map();
|
|
66
|
+
cuentas.set(slug, porEtiqueta);
|
|
67
|
+
}
|
|
68
|
+
porEtiqueta.set(label, (porEtiqueta.get(label) ?? 0) + 1);
|
|
69
|
+
}
|
|
70
|
+
const out = new Map();
|
|
71
|
+
for (const [slug, porEtiqueta] of cuentas) {
|
|
72
|
+
let mejor = '';
|
|
73
|
+
let mejorN = -1;
|
|
74
|
+
// `>` estricto: al recorrer en orden de aparición, la primera etiqueta (la
|
|
75
|
+
// más reciente) se queda cuando hay empate de frecuencia.
|
|
76
|
+
for (const [label, n] of porEtiqueta) {
|
|
77
|
+
if (n > mejorN) {
|
|
78
|
+
mejor = label;
|
|
79
|
+
mejorN = n;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
out.set(slug, mejor);
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
export function canonicalModule(label, aliases) {
|
|
87
|
+
const limpia = (label ?? '').trim();
|
|
88
|
+
if (!limpia || aliases.size === 0)
|
|
89
|
+
return limpia;
|
|
90
|
+
return aliases.get(slugModule(limpia)) ?? limpia;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* El punto de estrangulamiento: se llama al traer las filas, antes de agregar.
|
|
94
|
+
*
|
|
95
|
+
* ★ `deps` TAMBIÉN (2026-07-31): `change_module.deps` es un array DE NOMBRES DE
|
|
96
|
+
* MÓDULO y `dependentsOf` los casa por texto. Resolver solo la columna dejaba
|
|
97
|
+
* una arista declarada bajo la etiqueta vieja apuntando a un nodo que ya no se
|
|
98
|
+
* llama así, y el dependiente desaparecía del radio de impacto en silencio.
|
|
99
|
+
*/
|
|
100
|
+
export function canonicalizeModuleRows(rows, aliases) {
|
|
101
|
+
if (aliases.size === 0)
|
|
102
|
+
return rows;
|
|
103
|
+
return rows.map((r) => {
|
|
104
|
+
const crudo = r;
|
|
105
|
+
const module = crudo.module
|
|
106
|
+
? canonicalModule(crudo.module, aliases)
|
|
107
|
+
: crudo.module;
|
|
108
|
+
if (!Array.isArray(crudo.deps)) {
|
|
109
|
+
return crudo.module ? { ...r, module } : r;
|
|
110
|
+
}
|
|
111
|
+
// Se deduplica: dos aristas hacia las dos etiquetas de un módulo fusionado
|
|
112
|
+
// son UNA arista, y dejarlas contaría dos veces al mismo dependiente.
|
|
113
|
+
const deps = [
|
|
114
|
+
...new Set(crudo.deps.map((d) => typeof d === 'string' ? canonicalModule(d, aliases) : d)),
|
|
115
|
+
];
|
|
116
|
+
return { ...r, module, deps };
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Todas las etiquetas CRUDAS que acaban en el mismo canónico, incluida la propia.
|
|
121
|
+
*
|
|
122
|
+
* ESPEJO de `expandModuleNames` en analysis.ts, y existe por lo mismo: el CLI
|
|
123
|
+
* también filtra EN EL SERVIDOR por nombre de módulo (`module=in.(…)` sobre
|
|
124
|
+
* `regression_alerts` y `change_module`). En la base siguen las etiquetas viejas
|
|
125
|
+
* —el alias se resuelve al leer, no se reescribe nada—, así que preguntar por el
|
|
126
|
+
* canónico a secas PIERDE justo las filas que la fusión pretendía recuperar.
|
|
127
|
+
* Se expande ANTES de consultar y se canonicaliza DESPUÉS.
|
|
128
|
+
*/
|
|
129
|
+
export function expandModuleNames(names, hermanas) {
|
|
130
|
+
if (hermanas.size === 0)
|
|
131
|
+
return names;
|
|
132
|
+
const out = new Set();
|
|
133
|
+
for (const n of names) {
|
|
134
|
+
out.add(n);
|
|
135
|
+
for (const h of hermanas.get(slugModule(n)) ?? [])
|
|
136
|
+
out.add(h);
|
|
137
|
+
}
|
|
138
|
+
return [...out];
|
|
139
|
+
}
|
|
140
|
+
/** canónico (slug) → etiquetas crudas que resuelven a él. ESPEJO de analysis.ts. */
|
|
141
|
+
export function siblingLabels(filas, aliases) {
|
|
142
|
+
// Se agrupa por el canónico YA RESUELTO, no por el de cada fila: una cadena
|
|
143
|
+
// A→B→C tiene que dejar A, B y C en el mismo grupo, no en dos.
|
|
144
|
+
const grupos = new Map();
|
|
145
|
+
for (const f of filas) {
|
|
146
|
+
const label = (f.alias_label ?? '').trim();
|
|
147
|
+
if (!label)
|
|
148
|
+
continue;
|
|
149
|
+
const canonico = canonicalModule(label, aliases);
|
|
150
|
+
const clave = slugModule(canonico);
|
|
151
|
+
const g = grupos.get(clave) ?? new Set([canonico]);
|
|
152
|
+
g.add(label);
|
|
153
|
+
grupos.set(clave, g);
|
|
154
|
+
}
|
|
155
|
+
// Se indexa por CADA miembro: preguntar por la etiqueta vieja o por la buena
|
|
156
|
+
// tiene que devolver el grupo entero, porque quien consulta no sabe cuál tiene.
|
|
157
|
+
const out = new Map();
|
|
158
|
+
for (const g of grupos.values()) {
|
|
159
|
+
const miembros = [...g];
|
|
160
|
+
for (const m of miembros)
|
|
161
|
+
out.set(slugModule(m), miembros);
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* El id del proyecto que ya lleva dentro el filtro `project_id=eq.…`.
|
|
167
|
+
*
|
|
168
|
+
* Los tres lectores del CLI resuelven el proyecto para poder filtrar y ninguno
|
|
169
|
+
* se queda con el id. Sacarlo de aquí evita una segunda resolución (que en
|
|
170
|
+
* producción tarda ~590 ms, medido) por el gusto de tener la misma cadena.
|
|
171
|
+
*/
|
|
172
|
+
export function projectIdOf(projectFilter) {
|
|
173
|
+
return /project_id=eq\.([0-9a-f-]+)/.exec(projectFilter)?.[1] ?? null;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Los alias del proyecto. Best-effort como el resto del contexto del CLI: sin
|
|
177
|
+
* tabla (versión vieja del servidor) o con error, contexto vacío y todo se
|
|
178
|
+
* comporta como antes de que existiera. Un guardián caído por leer alias sería
|
|
179
|
+
* mucho peor que un guardián que no los resuelve.
|
|
180
|
+
*
|
|
181
|
+
* ES LA ÚNICA LECTURA DE `module_alias` EN EL CLI, a propósito: la identidad del
|
|
182
|
+
* módulo ya vivió en tres sitios una vez y divergió sin ruido.
|
|
183
|
+
*/
|
|
184
|
+
export async function aliasesFor(db, projectId) {
|
|
185
|
+
if (!projectId)
|
|
186
|
+
return SIN_ALIAS;
|
|
187
|
+
try {
|
|
188
|
+
const rows = await db.rest(`module_alias?select=alias_slug,alias_label,canonical&project_id=eq.${projectId}&limit=500`);
|
|
189
|
+
const directo = new Map();
|
|
190
|
+
for (const r of rows) {
|
|
191
|
+
const from = (r.alias_slug ?? '').trim();
|
|
192
|
+
const to = (r.canonical ?? '').trim();
|
|
193
|
+
if (from && to)
|
|
194
|
+
directo.set(from, to);
|
|
195
|
+
}
|
|
196
|
+
const aliases = resolveAliasChains(directo);
|
|
197
|
+
return { aliases, hermanas: siblingLabels(rows, aliases) };
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return SIN_ALIAS;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
//# sourceMappingURL=aliasDeModulo.js.map
|
package/dist/analyze.js
CHANGED
|
@@ -6,9 +6,37 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import * as path from "node:path";
|
|
8
8
|
import { atlasWebUrl } from "./browser.js";
|
|
9
|
-
import { commitDiff, execFileAsync, FICHEROS_GENERADOS, GIT_MAX_BUFFER_BYTES, gitErrorMessage, MAX_DIFF_CHARACTERS, projectNameFor, usableSummary, } from "./git.js";
|
|
9
|
+
import { commitDiff, esFicheroGenerado, execFileAsync, FICHEROS_GENERADOS, ficherosDelCommit, GIT_MAX_BUFFER_BYTES, gitErrorMessage, MAX_DIFF_CHARACTERS, projectNameFor, usableSummary, } from "./git.js";
|
|
10
10
|
import { canonicalDiffHash } from "./canonical.js";
|
|
11
11
|
import { optimizeTokensForAI, truncateAtFileBoundary } from "./optimize.js";
|
|
12
|
+
/**
|
|
13
|
+
* Por qué se salta un commit sin diff analizable. Tres causas, tres frases.
|
|
14
|
+
*
|
|
15
|
+
* HASTA EL 04/08 ERA UNA SOLA, Y ADIVINABA: «has no analyzable diff (merge?)».
|
|
16
|
+
* `commitDiff` excluye los ficheros que ChangeBook genera —`CLAUDE.md`,
|
|
17
|
+
* `AGENTS.md`— para no registrar sus propias regeneraciones en bucle, así que un
|
|
18
|
+
* commit normal que solo toque esos devuelve diff vacío igual que un merge. El
|
|
19
|
+
* `0bac769` de este repo era exactamente eso y el mensaje mandaba a buscar un
|
|
20
|
+
* merge que no existía.
|
|
21
|
+
*
|
|
22
|
+
* Saltar sigue siendo lo correcto en los tres casos: lo que cambia es que el
|
|
23
|
+
* instrumento sepa distinguirlos en vez de elegir uno (invariante 17 del repo).
|
|
24
|
+
*
|
|
25
|
+
* Es una función aparte y pura para que se pueda medir la ELECCIÓN sin montar
|
|
26
|
+
* un repo ni capturar `console.error`.
|
|
27
|
+
*/
|
|
28
|
+
export function motivoDelSalto(hash, ficheros) {
|
|
29
|
+
const corto = hash.slice(0, 8);
|
|
30
|
+
if (!ficheros.length) {
|
|
31
|
+
return `Commit ${corto} no toca ningun fichero (merge o commit vacio). Skipped.`;
|
|
32
|
+
}
|
|
33
|
+
if (ficheros.every(esFicheroGenerado)) {
|
|
34
|
+
return `Commit ${corto} solo toca ficheros que genera ChangeBook (${ficheros.join(", ")}). Saltado a proposito: regenerar el mapa no es un cambio de producto.`;
|
|
35
|
+
}
|
|
36
|
+
// Ni vacío ni todo generado: aquí no hay explicación conocida, y decirlo así
|
|
37
|
+
// es el punto. Inventar una tercera causa plausible sería repetir el bug.
|
|
38
|
+
return `Commit ${corto} toca ${ficheros.length} fichero(s) y aun asi no deja diff que analizar. Skipped. Ficheros: ${ficheros.join(", ")}`;
|
|
39
|
+
}
|
|
12
40
|
export async function analyze(db, options = {}) {
|
|
13
41
|
const cwd = path.resolve(options.dir ?? process.cwd());
|
|
14
42
|
const projectName = projectNameFor(cwd);
|
|
@@ -27,7 +55,17 @@ export async function analyze(db, options = {}) {
|
|
|
27
55
|
agentSummary = usableSummary(meta.message) ?? undefined;
|
|
28
56
|
rawDiff = await commitDiff(cwd, meta.hash);
|
|
29
57
|
if (!rawDiff.trim()) {
|
|
30
|
-
|
|
58
|
+
// Un diff vacío tiene DOS causas y hasta el 04/08 se contaban como una:
|
|
59
|
+
// el commit no toca nada (un merge), o toca solo ficheros que ChangeBook
|
|
60
|
+
// genera y que `commitDiff` excluye a propósito. Decir «(merge?)» de lo
|
|
61
|
+
// segundo es adivinar, y adivinar mal: `0bac769` era un commit normal de
|
|
62
|
+
// CLAUDE.md y AGENTS.md, y el mensaje mandaba a buscar un merge que no
|
|
63
|
+
// existía.
|
|
64
|
+
//
|
|
65
|
+
// Saltarlo sigue siendo lo correcto en los dos casos. Lo que cambia es
|
|
66
|
+
// que ahora el instrumento sabe por qué está saltando (invariante 17).
|
|
67
|
+
const ficheros = await ficherosDelCommit(cwd, meta.hash);
|
|
68
|
+
console.error(motivoDelSalto(meta.hash, ficheros));
|
|
31
69
|
return;
|
|
32
70
|
}
|
|
33
71
|
}
|
package/dist/badge.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { projectNameFor } from './git.js';
|
|
2
|
+
import { slugifyProject } from './guard.js';
|
|
3
|
+
import { REPO_JOVEN } from './scan.js';
|
|
4
|
+
/**
|
|
5
|
+
* Del informe a la fila. Pura, para poder probar la regla del repo joven sin
|
|
6
|
+
* red: es la que decide si el badge enseña un porcentaje o se calla.
|
|
7
|
+
*/
|
|
8
|
+
export function filaDeBadge(informe, ids, ahora = new Date().toISOString()) {
|
|
9
|
+
return {
|
|
10
|
+
...ids,
|
|
11
|
+
commits: informe.commits,
|
|
12
|
+
ficheros_vivos: informe.ficherosVivos,
|
|
13
|
+
concentracion_ficheros: informe.concentracion.ficheros,
|
|
14
|
+
concentracion_pct: informe.concentracion.porcentaje,
|
|
15
|
+
// MISMO umbral que usa `scan` para callar el porcentaje en pantalla. Si los
|
|
16
|
+
// dos no coinciden, el badge afirma lo que el informe se negó a afirmar.
|
|
17
|
+
joven: informe.commits < REPO_JOVEN,
|
|
18
|
+
updated_at: ahora,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/** El markdown que el dueño pega en su README. */
|
|
22
|
+
export function snippetDeBadge(base, token) {
|
|
23
|
+
const img = `${base}/functions/v1/badge?token=${token}`;
|
|
24
|
+
return `[](https://changebook.app)`;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Lo que se hace público al encender el badge, dicho ANTES de encenderlo.
|
|
28
|
+
*
|
|
29
|
+
* No es cortesía: la primera versión ataba el badge a `share_token`, o sea que
|
|
30
|
+
* pedir una imagen publicaba el atlas entero —módulos, riesgos y el resumen en
|
|
31
|
+
* llano de cada cambio—. Son dos permisos de tamaños muy distintos y juntarlos
|
|
32
|
+
* hacía que el pequeño costara el precio del grande.
|
|
33
|
+
*/
|
|
34
|
+
export const LO_QUE_SE_PUBLICA = [
|
|
35
|
+
'El badge hace públicos CUATRO NÚMEROS, y nada más:',
|
|
36
|
+
' · cuántos commits tiene el repo',
|
|
37
|
+
' · cuántos ficheros vivos',
|
|
38
|
+
' · cuántos de ellos concentran la mitad de los cambios, y su porcentaje',
|
|
39
|
+
'',
|
|
40
|
+
'NO se publica: ni código, ni diffs, ni nombres de fichero, ni nombres de',
|
|
41
|
+
'módulo, ni resúmenes de tus cambios, ni fechas. Es una llave aparte de la de',
|
|
42
|
+
'compartir el atlas: encender el badge NO publica tu historial.',
|
|
43
|
+
'',
|
|
44
|
+
'Se apaga cuando quieras con: changebook scan --badge --off',
|
|
45
|
+
].join('\n');
|
|
46
|
+
/**
|
|
47
|
+
* El proyecto de este repo, con la MISMA cascada que usa `projectFilterFor`:
|
|
48
|
+
* slug primero, luego nombre, y si la cuenta solo tiene uno, ése.
|
|
49
|
+
*
|
|
50
|
+
* Buscar por `name=eq.` a secas —que es lo que hacía la primera versión— falla
|
|
51
|
+
* en cuanto la carpeta no se llama exactamente igual que el proyecto: aquí el
|
|
52
|
+
* directorio es `AppAtlas` y el proyecto `appatlas`, y el comando contestaba
|
|
53
|
+
* «no hay ningún proyecto llamado AppAtlas» teniéndolo delante. Se vio
|
|
54
|
+
* ejecutándolo, no leyéndolo.
|
|
55
|
+
*/
|
|
56
|
+
export async function proyectoDe(db, nombre) {
|
|
57
|
+
const campos = 'select=id,user_id,badge_token';
|
|
58
|
+
const slug = slugifyProject(nombre);
|
|
59
|
+
const buscar = (q) => db.rest(`projects?${campos}&${q}&limit=1`);
|
|
60
|
+
if (slug) {
|
|
61
|
+
const porSlug = await buscar(`slug=eq.${encodeURIComponent(slug)}`);
|
|
62
|
+
if (porSlug[0])
|
|
63
|
+
return porSlug[0];
|
|
64
|
+
}
|
|
65
|
+
const porNombre = await buscar(`name=eq.${encodeURIComponent(nombre)}`);
|
|
66
|
+
if (porNombre[0])
|
|
67
|
+
return porNombre[0];
|
|
68
|
+
// Con UN solo proyecto no hay frontera que proteger: no hay entre qué elegir.
|
|
69
|
+
const todos = await db.rest(`projects?${campos}&order=created_at.asc&limit=2`);
|
|
70
|
+
return todos.length === 1 ? todos[0] : null;
|
|
71
|
+
}
|
|
72
|
+
const SIN_PROYECTO = (nombre) => `No hay ningún proyecto llamado "${nombre}" en tu cuenta de ChangeBook.\n` +
|
|
73
|
+
`Analiza al menos un commit primero (changebook analyze) y vuelve a intentarlo.`;
|
|
74
|
+
/**
|
|
75
|
+
* Publica los números y devuelve el snippet, encendiendo la llave si hace falta.
|
|
76
|
+
*
|
|
77
|
+
* LA LLAVE SE ENCIENDE AQUÍ, y es una decisión que cambió. La primera versión
|
|
78
|
+
* se apoyaba en `share_token` y se negaba a crear nada: encender ESE token
|
|
79
|
+
* habría convertido «quiero una imagen» en «publico mi atlas entero», que no es
|
|
80
|
+
* lo mismo ni de lejos. Con llave propia el cálculo es otro: lo que se expone
|
|
81
|
+
* son exactamente los cuatro números que el usuario va a ver dibujados, y ha
|
|
82
|
+
* escrito el comando que los pide. Encenderla en silencio seguiría estando mal,
|
|
83
|
+
* así que el comando imprime `LO_QUE_SE_PUBLICA` antes.
|
|
84
|
+
*/
|
|
85
|
+
export async function publicarBadge(db, dir, informe) {
|
|
86
|
+
const nombre = projectNameFor(dir);
|
|
87
|
+
const proyecto = await proyectoDe(db, nombre);
|
|
88
|
+
if (!proyecto)
|
|
89
|
+
return { ok: false, motivo: SIN_PROYECTO(nombre) };
|
|
90
|
+
// LOS NÚMEROS PRIMERO, LA LLAVE DESPUÉS. El orden no es indiferente: encender
|
|
91
|
+
// la llave abre una URL pública, y hacerlo antes de saber si hay algo que
|
|
92
|
+
// servir deja el badge encendido aunque la publicación falle. Pasó de verdad
|
|
93
|
+
// —el primer intento reventó con un 42501 DESPUÉS de haber encendido la
|
|
94
|
+
// llave—, y el usuario vio un error creyendo que no había pasado nada
|
|
95
|
+
// mientras la URL ya existía. Un permiso se abre cuando ya hay algo detrás.
|
|
96
|
+
//
|
|
97
|
+
// Por RPC y no por un upsert a la tabla. Un upsert escribe TODAS las columnas
|
|
98
|
+
// enviadas, incluidas `project_id` y `user_id`, que están fuera del grant por
|
|
99
|
+
// columna a propósito: son la frontera entre cuentas. Medido ejecutándolo —
|
|
100
|
+
// 42501— y la salida fácil (añadirlas al grant) es el agujero que
|
|
101
|
+
// 20260710120000 cerró en `regression_alerts`. La función valida la propiedad
|
|
102
|
+
// y el cliente se queda sin escritura directa.
|
|
103
|
+
const fila = filaDeBadge(informe, {
|
|
104
|
+
project_id: proyecto.id,
|
|
105
|
+
user_id: proyecto.user_id,
|
|
106
|
+
});
|
|
107
|
+
await db.callRpc('badge_publicar', {
|
|
108
|
+
p_project_id: fila.project_id,
|
|
109
|
+
p_commits: fila.commits,
|
|
110
|
+
p_ficheros_vivos: fila.ficheros_vivos,
|
|
111
|
+
p_concentracion_ficheros: fila.concentracion_ficheros,
|
|
112
|
+
p_concentracion_pct: fila.concentracion_pct,
|
|
113
|
+
p_joven: fila.joven,
|
|
114
|
+
});
|
|
115
|
+
// El token lo genera la BASE, no el cliente: uno elegido por quien lo usa se
|
|
116
|
+
// puede adivinar o reutilizar entre proyectos. `coalesce` dentro de la función
|
|
117
|
+
// hace que reencenderlo devuelva el mismo, así que republicar no invalida el
|
|
118
|
+
// badge que ya está pegado en un README.
|
|
119
|
+
const token = proyecto.badge_token ??
|
|
120
|
+
(await db.callRpc('badge_encender', {
|
|
121
|
+
p_project_id: proyecto.id,
|
|
122
|
+
}));
|
|
123
|
+
// `callRpc` devuelve null ante un cuerpo vacío (204) en vez de lanzar, que es
|
|
124
|
+
// lo correcto para las RPC `void` — pero aquí un null significaría imprimir
|
|
125
|
+
// una URL con la palabra «null» dentro y llamarlo éxito. Se para en seco: un
|
|
126
|
+
// snippet roto pegado en un README es peor que un error en la terminal.
|
|
127
|
+
if (!token) {
|
|
128
|
+
return {
|
|
129
|
+
ok: false,
|
|
130
|
+
motivo: 'La base no devolvió la llave del badge. No se ha impreso ningún\n' +
|
|
131
|
+
'snippet: pegar una URL incompleta en tu README sería peor. Vuelve a\n' +
|
|
132
|
+
'intentarlo, y si sigue, es un fallo del servidor y no tuyo.',
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
ok: true,
|
|
137
|
+
snippet: snippetDeBadge(db.baseUrl, token),
|
|
138
|
+
nuevo: !proyecto.badge_token,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Apaga el badge: borra la llave y los números.
|
|
143
|
+
*
|
|
144
|
+
* Los números se van con la llave a propósito. Si el dueño retira el badge, los
|
|
145
|
+
* datos que solo existían para dibujarlo no se quedan ahí esperando a que
|
|
146
|
+
* alguien los vuelva a exponer.
|
|
147
|
+
*/
|
|
148
|
+
export async function apagarBadge(db, dir) {
|
|
149
|
+
const nombre = projectNameFor(dir);
|
|
150
|
+
const proyecto = await proyectoDe(db, nombre);
|
|
151
|
+
if (!proyecto)
|
|
152
|
+
return { ok: false, motivo: SIN_PROYECTO(nombre) };
|
|
153
|
+
if (!proyecto.badge_token) {
|
|
154
|
+
return { ok: false, motivo: `"${nombre}" no tiene ningún badge encendido.` };
|
|
155
|
+
}
|
|
156
|
+
await db.callRpc('badge_apagar', { p_project_id: proyecto.id });
|
|
157
|
+
return { ok: true, snippet: '', apagado: true };
|
|
158
|
+
}
|
|
159
|
+
//# sourceMappingURL=badge.js.map
|
package/dist/context.js
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
import * as fs from "node:fs";
|
|
25
25
|
import path from "node:path";
|
|
26
26
|
import { feedWarningFor } from "./feed.js";
|
|
27
|
+
import { avisoDeHookMudoPara } from "./hookMudo.js";
|
|
27
28
|
import { fetchBriefSection } from "./sync.js";
|
|
28
29
|
import { commitAliasesShort, derivaContraHead } from "./tools.js";
|
|
29
30
|
/** Hard ceiling on the critical path: past this, emit nothing and move on. */
|
|
@@ -204,6 +205,11 @@ async function buildSessionContext(db, dir) {
|
|
|
204
205
|
// Local and offline: one small file plus a git rev-parse. It cannot fail
|
|
205
206
|
// because of the network or the session, which is the point.
|
|
206
207
|
const pulse = await feedWarningFor(dir, { audience: "agent" }).catch(() => "");
|
|
208
|
+
// El segundo silencio (C6): el aviso previo a editar puede llevar semanas sin
|
|
209
|
+
// darse y el agente no tiene forma de notarlo — para él, «no me dijeron nada»
|
|
210
|
+
// y «no había nada que decir» son la misma frase. Por eso se le dice a él
|
|
211
|
+
// también y no solo al humano en el pre-commit.
|
|
212
|
+
const mudo = await avisoDeHookMudoPara(dir, { audience: "agent" }).catch(() => "");
|
|
207
213
|
let brief = null;
|
|
208
214
|
try {
|
|
209
215
|
brief = await buildPayload(db, dir);
|
|
@@ -211,7 +217,7 @@ async function buildSessionContext(db, dir) {
|
|
|
211
217
|
catch {
|
|
212
218
|
brief = null;
|
|
213
219
|
}
|
|
214
|
-
const parts = [pulse, brief].filter(Boolean);
|
|
220
|
+
const parts = [pulse, mudo, brief].filter(Boolean);
|
|
215
221
|
return parts.length > 0 ? parts.join("\n\n") : null;
|
|
216
222
|
}
|
|
217
223
|
export async function printContext(db, dir) {
|
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
|
|
79
|
-
*
|
|
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" &&
|