changebook 0.3.2 → 0.4.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/README.md CHANGED
@@ -34,7 +34,9 @@ npx changebook init # login (browser) + register in Claude Code/Codex + sync
34
34
  (one Authorize click — no token copy-pasting), registers the MCP server in
35
35
  **every coding agent it finds on the machine** — Claude Code, Codex, Cursor,
36
36
  Windsurf, Claude Desktop and VS Code (Copilot agent mode) — installs the
37
- post-commit hook so the atlas updates itself, and writes the product map
37
+ git hooks (post-commit: the atlas updates itself; pre-commit: the signal
38
+ guard warns before touching a module with an open alert), and writes the
39
+ product map
38
40
  into the project's `CLAUDE.md`/`AGENTS.md`. It only touches agents that are
39
41
  actually installed, and merges into existing MCP configs without clobbering
40
42
  your other servers.
@@ -47,7 +49,8 @@ your other servers.
47
49
  | `changebook logout` | Forget the stored session. |
48
50
  | `changebook analyze [dir]` | Analyze the repo's uncommitted changes (`git diff HEAD`) and update the atlas — same pipeline as the VS Code extension, no editor needed. |
49
51
  | `changebook analyze --commit [ref]` | Analyze one commit. Deduped by hash server-side, so re-runs never bill. |
50
- | `changebook hook install\|uninstall\|status [dir]` | Git post-commit hook: every new commit is analyzed in the background (never blocks the commit). One hook covers Claude Code, Codex and manual commits — they all commit through git. |
52
+ | `changebook hook install\|uninstall\|status [dir]` | Git hooks: every new commit is analyzed in the background (post-commit, never blocks), and the signal guard warns before you commit to a module with an open alert (pre-commit). One pair of hooks covers Claude Code, Codex and manual commits — they all commit through git. |
53
+ | `changebook guard [dir]` | What the pre-commit hook runs: checks staged files against the atlas' open alerts. Warn-only and fail-open by default; `CHANGEBOOK_GUARD=block` makes findings abort the commit (bypass once with `git commit --no-verify`), `CHANGEBOOK_GUARD=off` silences it. |
51
54
  | `changebook sync [dir]` | Refresh the product map inside `CLAUDE.md`/`AGENTS.md`. |
52
55
  | `changebook init [dir]` | login + register MCP server + install hook + sync, in one go. |
53
56
  | `changebook open` | Open the web atlas in the browser. |
package/dist/analyze.js CHANGED
@@ -6,7 +6,8 @@
6
6
  */
7
7
  import * as path from "node:path";
8
8
  import { atlasWebUrl } from "./browser.js";
9
- import { commitDiff, execFileAsync, GIT_MAX_BUFFER_BYTES, gitErrorMessage, MAX_DIFF_CHARACTERS, } from "./git.js";
9
+ import { commitDiff, execFileAsync, GIT_MAX_BUFFER_BYTES, gitErrorMessage, MAX_DIFF_CHARACTERS, usableSummary, } from "./git.js";
10
+ import { canonicalDiffHash } from "./canonical.js";
10
11
  import { optimizeTokensForAI, truncateAtFileBoundary } from "./optimize.js";
11
12
  export async function analyze(db, options = {}) {
12
13
  const cwd = path.resolve(options.dir ?? process.cwd());
@@ -14,10 +15,16 @@ export async function analyze(db, options = {}) {
14
15
  let rawDiff;
15
16
  let commitHash;
16
17
  let committedAt;
18
+ // El mensaje del commit como resumen: enruta al modelo pequeño en el servidor
19
+ // (~3 veces más barato). Este es el camino del hook post-commit, o sea el que
20
+ // dispara en CADA commit, así que es donde más veces se cobra la diferencia.
21
+ // Analizando cambios sin commitear no hay mensaje todavía: va sin resumen.
22
+ let agentSummary;
17
23
  if (options.commit) {
18
24
  const meta = await commitMeta(cwd, options.commit);
19
25
  commitHash = meta.hash;
20
26
  committedAt = meta.committedAt;
27
+ agentSummary = usableSummary(meta.message) ?? undefined;
21
28
  rawDiff = await commitDiff(cwd, meta.hash);
22
29
  if (!rawDiff.trim()) {
23
30
  console.error(`Commit ${meta.hash.slice(0, 8)} has no analyzable diff (merge?). Skipped.`);
@@ -46,9 +53,13 @@ export async function analyze(db, options = {}) {
46
53
  const { status, body } = await db.invokeFunction("analyze-diff", {
47
54
  compressedDiff,
48
55
  rawDiffChars: rawDiff.length,
56
+ // Identidad de contenido calculada sobre el diff CRUDO, antes de
57
+ // comprimir: es lo único que coincide entre este hook y el webhook.
58
+ rawContentHash: canonicalDiffHash(rawDiff),
49
59
  projectName,
50
60
  commitHash,
51
61
  committedAt,
62
+ agentSummary,
52
63
  });
53
64
  if (status !== 200 || body.error) {
54
65
  // The server's messages are user-facing (quota, waitlist…): pass through.
@@ -90,11 +101,22 @@ async function commitMeta(cwd, ref) {
90
101
  const { stdout } = await execFileAsync("git",
91
102
  // --end-of-options so a user-supplied ref beginning with "-" is treated as
92
103
  // a revision, not as a git option.
93
- ["log", "-1", "--pretty=format:%H|%cI", "--end-of-options", ref], { cwd, encoding: "utf8" });
94
- const [hash, committedAt] = stdout.trim().split("|");
104
+ // %B (mensaje entero) va detrás de la fecha a propósito: es el único
105
+ // campo que puede llevar "|" y saltos de línea, así que se corta por los
106
+ // DOS primeros separadores y el resto se queda entero. Con -1 no hace
107
+ // falta -z: solo hay un registro.
108
+ ["log", "-1", "--pretty=format:%H|%cI|%B", "--end-of-options", ref], { cwd, encoding: "utf8" });
109
+ const raw = stdout.trim();
110
+ const firstPipe = raw.indexOf("|");
111
+ const secondPipe = raw.indexOf("|", firstPipe + 1);
112
+ const hash = firstPipe < 0 ? raw : raw.slice(0, firstPipe);
95
113
  if (!hash)
96
114
  throw new Error("empty git log output");
97
- return { hash, committedAt };
115
+ return {
116
+ hash,
117
+ committedAt: secondPipe < 0 ? raw.slice(firstPipe + 1) : raw.slice(firstPipe + 1, secondPipe),
118
+ message: secondPipe < 0 ? "" : raw.slice(secondPipe + 1),
119
+ };
98
120
  }
99
121
  catch (error) {
100
122
  throw new Error(`Could not resolve commit "${ref}" in "${cwd}": ${gitErrorMessage(error)}`);
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Canonical content hash of a unified diff — mirror of the server's
3
+ * canonicalDiffContent/canonicalDiffHash in supabase/functions/_shared/
4
+ * analysis.ts. The npm package must be self-contained, so this is a
5
+ * deliberate duplicate pinned equal by test/canonicalParity.test.ts: drift
6
+ * breaks CI, never the dedup.
7
+ *
8
+ * Why the CLIENT computes it, on the RAW diff, BEFORE compressing: the CLI
9
+ * compresses with optimize.ts and the GitHub webhook sends the API patch
10
+ * as-is, so the texts that reach the server for the SAME change differ and
11
+ * no server-side hash can reconcile them (visto en vivo 2026-07-18: el
12
+ * squash de la PR #165 no dedupeó contra el análisis del hook). Only the raw
13
+ * content, canonicalized, is a stable identity across senders.
14
+ */
15
+ import { createHash } from 'node:crypto';
16
+ export function canonicalDiffContent(diff) {
17
+ const out = [];
18
+ let minusPath = '';
19
+ for (const line of diff.split('\n')) {
20
+ if (line.startsWith('--- a/') || line === '--- /dev/null') {
21
+ minusPath = line === '--- /dev/null' ? '' : line.slice(6).trim();
22
+ continue;
23
+ }
24
+ if (line.startsWith('+++ b/') || line === '+++ /dev/null') {
25
+ const path = line === '+++ /dev/null' ? minusPath : line.slice(6).trim();
26
+ out.push(`F:${path}`);
27
+ continue;
28
+ }
29
+ if (line.startsWith('Binary files ')) {
30
+ out.push(line);
31
+ continue;
32
+ }
33
+ if (line.startsWith('+') || line.startsWith('-'))
34
+ out.push(line);
35
+ }
36
+ return out.join('\n');
37
+ }
38
+ export function canonicalDiffHash(diff) {
39
+ const canonical = canonicalDiffContent(diff);
40
+ return createHash('sha256')
41
+ .update((canonical || diff).replace(/\s+/g, ''))
42
+ .digest('hex');
43
+ }
44
+ //# sourceMappingURL=canonical.js.map
package/dist/git.js CHANGED
@@ -11,6 +11,40 @@ export const GIT_MAX_BUFFER_BYTES = 10 * 1024 * 1024;
11
11
  // Mirror of the extension's default changebook.maxDiffCharacters (the server
12
12
  // rejects anything above 60k anyway).
13
13
  export const MAX_DIFF_CHARACTERS = 25_000;
14
+ // ── El mensaje de commit como resumen ────────────────────────────────────────
15
+ // El mensaje, cuando dice algo, ES el resumen del cambio: lo escribió quien lo
16
+ // hizo y explica la intención, que es la mitad cara del análisis y la única que
17
+ // el servidor no puede reconstruir del diff. Mandarlo hace que el servidor
18
+ // enrute al modelo pequeño: medido, $0,063 contra $0,021 = 3 veces menos.
19
+ //
20
+ // Vive aquí por lo mismo que commitDiff: lo usan los DOS subcomandos (`analyze`,
21
+ // que es el hook post-commit, e `import`, que es la historia), y tenerlo en uno
22
+ // solo es exactamente el fallo que dejó al otro pagando el modelo caro.
23
+ //
24
+ // Copia deliberada de la del servidor (_shared/github.ts) y de la de la
25
+ // extensión (src/util/summary.ts): los tres artefactos se publican por separado
26
+ // y tienen que ser autocontenidos, igual que canonicalDiffHash.
27
+ // test/summaryParity.test.ts fija que no se separen — si lo hacen no falla
28
+ // nada, solo cambia la factura.
29
+ //
30
+ // Los dos filtros: un mensaje pobre ("fix", "wip") NO se manda, porque el
31
+ // modelo pequeño se APOYA en el resumen y alimentarlo con ruido daría un
32
+ // análisis peor Y más barato; y el largo se recorta por línea entera, porque el
33
+ // servidor RECHAZA los que se pasan y un 400 no encarecería el análisis: lo
34
+ // dejaría sin hacer.
35
+ const MIN_USEFUL_SUMMARY_CHARS = 40;
36
+ const MAX_AGENT_SUMMARY_CHARS = 2_400;
37
+ /** El mensaje si sirve como resumen, recortado por línea entera; si no, null. */
38
+ export function usableSummary(message) {
39
+ const text = message.trim();
40
+ if (text.length < MIN_USEFUL_SUMMARY_CHARS)
41
+ return null;
42
+ if (text.length <= MAX_AGENT_SUMMARY_CHARS)
43
+ return text;
44
+ const cut = text.slice(0, MAX_AGENT_SUMMARY_CHARS);
45
+ const lastBreak = cut.lastIndexOf("\n");
46
+ return (lastBreak > MIN_USEFUL_SUMMARY_CHARS ? cut.slice(0, lastBreak) : cut).trim();
47
+ }
14
48
  /** `git show <hash> -U0` — the unified diff of a single commit, no context. */
15
49
  export async function commitDiff(cwd, hash) {
16
50
  try {
package/dist/guard.js ADDED
@@ -0,0 +1,276 @@
1
+ /**
2
+ * `changebook guard` — the pre-commit signal guard. Checks the files staged
3
+ * for commit against the atlas' open regression alerts and warns when the
4
+ * commit is about to touch a module with an unresolved alert ("you're editing
5
+ * something that a previous change already broke").
6
+ *
7
+ * Contract with the pre-commit hook (see hook.ts):
8
+ * exit 0 — pass (including every failure mode: offline, logged out, no
9
+ * project in the atlas… a guard that blocks commits when the
10
+ * network is down would get uninstalled the same day)
11
+ * exit 3 — findings under CHANGEBOOK_GUARD=block; the hook maps it to a
12
+ * failed commit. Any OTHER non-zero exit (node missing, crash)
13
+ * deliberately does NOT block.
14
+ */
15
+ import * as fs from "node:fs";
16
+ import * as path from "node:path";
17
+ import { execFileAsync } from "./git.js";
18
+ /** Exit code that asks the pre-commit hook to abort the commit. */
19
+ export const EXIT_BLOCK = 3;
20
+ // A commit should never feel slow because of us: whatever the network hasn't
21
+ // answered by then is treated as "no findings".
22
+ const GUARD_TIMEOUT_MS = 3_500;
23
+ // Open alerts move at analysis speed (one per commit at most), so a short
24
+ // cache makes rebases/amend streaks free without risking stale warnings.
25
+ const CACHE_TTL_MS = 5 * 60_000;
26
+ const MAX_ALERTS = 20;
27
+ /**
28
+ * Same slug the backend derives from the project name (analysis.ts slugify):
29
+ * the CLI sends `path.basename(cwd)` as projectName and the server slugifies
30
+ * it, so reproducing that transform is how the guard finds its project row.
31
+ */
32
+ export function slugifyProject(value) {
33
+ return value
34
+ .normalize("NFD")
35
+ .replace(/[\u0300-\u036f]/g, "") // strip diacritics left by NFD
36
+ .toLowerCase()
37
+ .replace(/[^a-z0-9]+/g, "-")
38
+ .replace(/^-+|-+$/g, "")
39
+ .slice(0, 50);
40
+ }
41
+ /**
42
+ * PostgREST `in.(...)` list. Module labels are human text ("Perfil y cuenta")
43
+ * so every value is double-quoted with `"` and `\` escaped — a comma or paren
44
+ * inside a label must never split the list.
45
+ */
46
+ export function pgInList(values) {
47
+ return values
48
+ .map((v) => `"${v.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`)
49
+ .join(",");
50
+ }
51
+ /**
52
+ * change_module rows → the UNION of files each module touched recently. The
53
+ * union, not the latest row: a module's changes touch different files each
54
+ * time, and an alerted module's alert applies to ALL of them — matching only
55
+ * the newest snapshot silently missed real hits (visto en vivo 2026-07-18:
56
+ * PublicAtlas.tsx pertenece a «Páginas legales» por filas anteriores y el
57
+ * guardián calló ante su alerta abierta).
58
+ */
59
+ export function moduleFilesUnion(rows) {
60
+ const map = new Map();
61
+ for (const row of rows) {
62
+ const label = (row.module ?? "").trim();
63
+ if (!label)
64
+ continue;
65
+ const set = map.get(label) ?? new Set();
66
+ if (Array.isArray(row.files)) {
67
+ for (const f of row.files)
68
+ set.add(String(f));
69
+ }
70
+ map.set(label, set);
71
+ }
72
+ return new Map([...map.entries()].map(([k, v]) => [k, [...v]]));
73
+ }
74
+ /** Open alerts × staged files → warnings, deduped by (module, message). */
75
+ export function guardFindings(staged, alerts, filesByModule) {
76
+ const stagedSet = new Set(staged);
77
+ const seen = new Set();
78
+ const findings = [];
79
+ for (const alert of alerts) {
80
+ const module = (alert.module ?? "").trim();
81
+ const plain = (alert.plain ?? "").trim();
82
+ if (!module || !plain)
83
+ continue;
84
+ const touched = (filesByModule.get(module) ?? []).filter((f) => stagedSet.has(f));
85
+ if (touched.length === 0)
86
+ continue;
87
+ const key = module + "\u0000" + plain;
88
+ if (seen.has(key))
89
+ continue;
90
+ seen.add(key);
91
+ findings.push({ module, plain, staged: touched });
92
+ }
93
+ return findings;
94
+ }
95
+ async function gitPath(dir, name) {
96
+ const { stdout } = await execFileAsync("git", ["rev-parse", "--git-path", name], { cwd: dir, encoding: "utf8" });
97
+ return path.resolve(dir, stdout.trim());
98
+ }
99
+ export async function stagedFiles(dir) {
100
+ // -z: NUL-separated, and crucially git does NOT octal-quote non-ASCII paths
101
+ // (default quotepath would emit "m\303\263dulo.ts", which never matches the
102
+ // real UTF-8 path the atlas stores → the alert is silenced). -z also covers
103
+ // paths with newlines. Product en español: rutas con acentos son plausibles.
104
+ const { stdout } = await execFileAsync("git", ["diff", "--cached", "--name-only", "-z"], { cwd: dir, encoding: "utf8" });
105
+ return stdout.split("\0").filter(Boolean);
106
+ }
107
+ function loadCache(file) {
108
+ try {
109
+ const cache = JSON.parse(fs.readFileSync(file, "utf8"));
110
+ if (Date.now() - cache.fetched_at > CACHE_TTL_MS)
111
+ return null;
112
+ return cache;
113
+ }
114
+ catch {
115
+ return null;
116
+ }
117
+ }
118
+ async function fetchSignals(db, dir, env) {
119
+ const cacheFile = await gitPath(dir, "changebook-guard-cache.json").catch(() => null);
120
+ const cached = cacheFile ? loadCache(cacheFile) : null;
121
+ if (cached) {
122
+ return {
123
+ alerts: cached.alerts,
124
+ filesByModule: new Map(Object.entries(cached.files)),
125
+ projectId: cached.project_id,
126
+ fromCache: true,
127
+ };
128
+ }
129
+ // Same project the analyze/hook pipeline reports to: CHANGEBOOK_PROJECT
130
+ // wins, otherwise the directory name, matched by server-side slug first.
131
+ const candidate = env.CHANGEBOOK_PROJECT?.trim() || path.basename(path.resolve(dir));
132
+ const slug = slugifyProject(candidate);
133
+ let projects = slug
134
+ ? await db.rest(`projects?select=id&slug=eq.${encodeURIComponent(slug)}&limit=1`)
135
+ : [];
136
+ if (projects.length === 0) {
137
+ projects = await db.rest(`projects?select=id&name=eq.${encodeURIComponent(candidate)}&limit=1`);
138
+ }
139
+ let alerts = [];
140
+ let filesByModule = new Map();
141
+ const projectId = projects[0]?.id ?? null;
142
+ if (projectId) {
143
+ alerts = await db.rest(`regression_alerts?select=module,plain,created_at&project_id=eq.${projectId}&resolved_at=is.null&order=created_at.desc&limit=${MAX_ALERTS}`);
144
+ const modules = [
145
+ ...new Set(alerts.map((a) => (a.module ?? "").trim()).filter(Boolean)),
146
+ ];
147
+ if (modules.length > 0) {
148
+ const rows = await db.rest(`change_module?select=module,files,created_at&project_id=eq.${projectId}&module=in.(${encodeURIComponent(pgInList(modules))})&order=created_at.desc&limit=200`);
149
+ filesByModule = moduleFilesUnion(rows);
150
+ }
151
+ }
152
+ if (cacheFile) {
153
+ const cache = {
154
+ fetched_at: Date.now(),
155
+ project_id: projectId,
156
+ alerts,
157
+ files: Object.fromEntries(filesByModule),
158
+ };
159
+ try {
160
+ fs.writeFileSync(cacheFile, JSON.stringify(cache));
161
+ }
162
+ catch {
163
+ // Cache is an optimization; a read-only .git dir must not break the guard.
164
+ }
165
+ }
166
+ return { alerts, filesByModule, projectId, fromCache: false };
167
+ }
168
+ /** One-line last-run trace so "why didn't it warn?" is answerable. */
169
+ async function logRun(dir, message) {
170
+ try {
171
+ const file = await gitPath(dir, "changebook-guard.log");
172
+ fs.writeFileSync(file, `${new Date().toISOString()} ${message}\n`);
173
+ }
174
+ catch {
175
+ // Best-effort only.
176
+ }
177
+ }
178
+ /**
179
+ * El aviso que ve el humano, como texto.
180
+ *
181
+ * Se construye aparte de imprimirlo para poder MEDIRLO: es lo que el guardián
182
+ * sirve de verdad, y su longitud es lo que se registra como chars_served
183
+ * (QA 2026-07-19). Cadena vacía cuando no hay nada que avisar.
184
+ */
185
+ export function findingsMessage(findings, block) {
186
+ if (findings.length === 0)
187
+ return "";
188
+ const lines = [
189
+ `\n⚠ ChangeBook: ${findings.length === 1 ? "an open alert" : `${findings.length} open alerts`} on what you're about to commit:\n`,
190
+ ];
191
+ for (const f of findings) {
192
+ lines.push(` • ${f.module} — ${f.plain}`);
193
+ lines.push(` staged: ${f.staged.slice(0, 5).join(", ")}`);
194
+ }
195
+ lines.push(block
196
+ ? "\nCommit blocked (CHANGEBOOK_GUARD=block). Review the alert in your atlas (changebook open) or bypass once with: git commit --no-verify\n"
197
+ : "\nReview or dismiss the alert in your atlas: changebook open\n");
198
+ return lines.join("\n");
199
+ }
200
+ /**
201
+ * Returns the process exit code. Everything that can go wrong resolves to 0
202
+ * (pass): the guard informs, it does not gatekeep — except when the user
203
+ * explicitly opts into CHANGEBOOK_GUARD=block.
204
+ */
205
+ export async function runGuard(db, dir, env = process.env) {
206
+ const mode = (env.CHANGEBOOK_GUARD ?? "").trim().toLowerCase();
207
+ if (mode === "off")
208
+ return 0;
209
+ if (!db.hasCredentials())
210
+ return 0;
211
+ let staged;
212
+ try {
213
+ staged = await stagedFiles(dir);
214
+ }
215
+ catch {
216
+ return 0;
217
+ }
218
+ if (staged.length === 0)
219
+ return 0;
220
+ let signals;
221
+ try {
222
+ signals = await Promise.race([
223
+ fetchSignals(db, dir, env),
224
+ new Promise((resolve) => {
225
+ // The caller process.exit()s right after, so the losing fetch never
226
+ // holds the commit hostage; unref keeps the timer from doing so either.
227
+ setTimeout(() => resolve(null), GUARD_TIMEOUT_MS).unref();
228
+ }),
229
+ ]);
230
+ }
231
+ catch (error) {
232
+ await logRun(dir, `error: ${error instanceof Error ? error.message.slice(0, 200) : String(error)}`);
233
+ return 0;
234
+ }
235
+ if (signals === null) {
236
+ await logRun(dir, `timeout after ${GUARD_TIMEOUT_MS}ms — passing`);
237
+ return 0;
238
+ }
239
+ const findings = guardFindings(staged, signals.alerts, signals.filesByModule);
240
+ const block = mode === "block";
241
+ const message = findingsMessage(findings, block);
242
+ // La consulta del guardián también es una consulta del atlas (QA
243
+ // 2026-07-18: el contador solo veía las tools MCP y el trabajo más
244
+ // constante del atlas era invisible). Solo las frescas — un rebase servido
245
+ // de caché no re-consulta nada. Best-effort y ACOTADO: jamás puede
246
+ // convertir un commit rápido en uno lento.
247
+ //
248
+ // chars_served = el aviso que el humano acaba viendo, 0 cuando todo está
249
+ // limpio (QA 2026-07-19). Antes se registraba siempre 0 y el guardián, que es
250
+ // el consumidor MÁS frecuente del atlas, no se distinguía de una consulta que
251
+ // no encontró nada. Con esto "revisó" y "avisó" son dos cosas separables en el
252
+ // dato, sin inventar ninguna valoración: esos caracteres se sirvieron de
253
+ // verdad. Lo que el guardián evita de verdad, un error, no se mide en
254
+ // caracteres y no se intenta.
255
+ if (!signals.fromCache && signals.projectId) {
256
+ await Promise.race([
257
+ db
258
+ .insertRow("atlas_reads", {
259
+ project_id: signals.projectId,
260
+ tool: "guard_precommit",
261
+ source: "guard",
262
+ chars_served: message.length,
263
+ })
264
+ .catch(() => { }),
265
+ new Promise((resolve) => {
266
+ setTimeout(resolve, 800).unref();
267
+ }),
268
+ ]);
269
+ }
270
+ await logRun(dir, `staged=${staged.length} openAlerts=${signals.alerts.length} findings=${findings.length}`);
271
+ if (findings.length === 0)
272
+ return 0;
273
+ console.error(message);
274
+ return block ? EXIT_BLOCK : 0;
275
+ }
276
+ //# sourceMappingURL=guard.js.map
package/dist/hook.js CHANGED
@@ -1,33 +1,77 @@
1
1
  /**
2
- * `changebook hook install|uninstall|status [dir]` — git post-commit hook that
3
- * analyzes each new commit into the atlas, in the background, without ever
4
- * blocking the commit. One analysis per commit, deduped server-side by hash,
5
- * so the cost is bounded and re-runs are free. This one hook covers every
6
- * client that commits: Claude Code, Codex, the terminal, any editor.
2
+ * `changebook hook install|uninstall|status [dir]` — the pair of git hooks
3
+ * that make the atlas live:
4
+ *
5
+ * post-commit analyzes each new commit into the atlas, in the background,
6
+ * without ever blocking the commit. One analysis per commit,
7
+ * deduped server-side by hash, so the cost is bounded and
8
+ * re-runs are free.
9
+ * pre-commit the signal guard: warns (never blocks, unless the user opts
10
+ * into CHANGEBOOK_GUARD=block) when a staged file belongs to a
11
+ * module with an open regression alert.
12
+ *
13
+ * These two hooks cover every client that commits: Claude Code, Codex, the
14
+ * terminal, any editor.
7
15
  */
8
16
  import * as fs from "node:fs";
9
17
  import * as path from "node:path";
10
18
  import { fileURLToPath } from "node:url";
11
19
  import { execFileAsync } from "./git.js";
12
- const MARKER = "# changebook post-commit hook";
13
- function hookScript() {
14
- const entry = path.join(path.dirname(fileURLToPath(import.meta.url)), "index.js");
20
+ function cliEntry() {
21
+ return path.join(path.dirname(fileURLToPath(import.meta.url)), "index.js");
22
+ }
23
+ const POST_COMMIT_MARKER = "# changebook post-commit hook";
24
+ const PRE_COMMIT_MARKER = "# changebook pre-commit guard";
25
+ /** Exported for tests: the contract between this script and guard.ts. */
26
+ export function postCommitScript() {
15
27
  // Background subshell + `|| true`: a broken analyze (offline, out of
16
28
  // credits, logged out) must never make `git commit` fail or feel slow.
17
29
  // The log keeps only the last run so it can't grow unbounded.
18
30
  return `#!/bin/sh
19
- ${MARKER} — analyzes each commit into your ChangeBook atlas.
31
+ ${POST_COMMIT_MARKER} — analyzes each commit into your ChangeBook atlas.
20
32
  # Runs in the background and never blocks the commit. Remove with:
21
33
  # changebook hook uninstall
22
- ( ${JSON.stringify(process.execPath)} ${JSON.stringify(entry)} analyze --commit HEAD > "$(git rev-parse --git-dir)/changebook-hook.log" 2>&1 & ) || true
34
+ ( ${JSON.stringify(process.execPath)} ${JSON.stringify(cliEntry())} analyze --commit HEAD > "$(git rev-parse --git-dir)/changebook-hook.log" 2>&1 & ) || true
35
+ `;
36
+ }
37
+ /** Exported for tests: the contract between this script and guard.ts. */
38
+ export function preCommitScript() {
39
+ // Foreground (a warning printed after the commit would be pointless) but
40
+ // fail-open by exit-code contract: ONLY the guard's deliberate exit 3
41
+ // (CHANGEBOOK_GUARD=block with findings) aborts the commit. A missing node,
42
+ // a crash, a network error — any other status — lets the commit through.
43
+ // The if/elif form is immune to `set -e`: a non-zero guard exit (crash,
44
+ // node missing, exit 3) is consumed by the condition instead of aborting the
45
+ // script mid-line. This matters when the line is pasted into a foreign
46
+ // pre-commit that runs under `set -e` (classic Husky) — only the deliberate
47
+ // exit 3 must block, never a crash.
48
+ return `#!/bin/sh
49
+ ${PRE_COMMIT_MARKER} — warns when a staged file belongs to a module with an
50
+ # open ChangeBook alert. Warn-only unless CHANGEBOOK_GUARD=block. Remove with:
51
+ # changebook hook uninstall
52
+ if ${JSON.stringify(process.execPath)} ${JSON.stringify(cliEntry())} guard; then :; elif [ $? -eq 3 ]; then exit 1; fi
23
53
  `;
24
54
  }
25
- async function hookPath(dir) {
55
+ const HOOKS = [
56
+ {
57
+ name: "post-commit",
58
+ marker: POST_COMMIT_MARKER,
59
+ script: postCommitScript,
60
+ installedNote: "Every new commit is analyzed into your atlas in the background (1 credit each; re-runs of the same commit are free).",
61
+ },
62
+ {
63
+ name: "pre-commit",
64
+ marker: PRE_COMMIT_MARKER,
65
+ script: preCommitScript,
66
+ installedNote: "Before each commit, you'll be warned if a staged file belongs to a module with an open alert (set CHANGEBOOK_GUARD=block to make it blocking, =off to silence it).",
67
+ },
68
+ ];
69
+ async function hookPath(dir, name) {
26
70
  // --git-path (not --git-dir + "/hooks") resolves core.hooksPath — set by
27
71
  // Husky in most modern JS repos to .husky/ — and linked worktrees' common
28
- // dir. Building <git-dir>/hooks/post-commit by hand ignores both, so the hook
72
+ // dir. Building <git-dir>/hooks/<name> by hand ignores both, so the hook
29
73
  // installed to a path git never runs and the atlas silently stops updating.
30
- const { stdout } = await execFileAsync("git", ["rev-parse", "--git-path", "hooks/post-commit"], {
74
+ const { stdout } = await execFileAsync("git", ["rev-parse", "--git-path", `hooks/${name}`], {
31
75
  cwd: dir,
32
76
  encoding: "utf8",
33
77
  }).catch(() => {
@@ -36,34 +80,84 @@ async function hookPath(dir) {
36
80
  // The path is relative to `dir`.
37
81
  return path.resolve(dir, stdout.trim());
38
82
  }
83
+ /** Whether `child` resolves inside `parent` (not the same, actually within). */
84
+ function isInside(parent, child) {
85
+ const rel = path.relative(parent, child);
86
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
87
+ }
88
+ /**
89
+ * Refuse to install into a GLOBAL core.hooksPath. `git config --global
90
+ * core.hooksPath ~/.git-hooks` (a common setup) makes `--git-path hooks/…`
91
+ * resolve OUTSIDE this repo, so the post-commit would fire for EVERY repo on
92
+ * the machine — including private work repos — uploading their diffs and
93
+ * minting phantom projects. Husky's in-repo .husky/ stays inside the worktree
94
+ * and is fine; only a hooks dir outside both the worktree and the git-dir is
95
+ * the dangerous global case.
96
+ */
97
+ async function assertHookInsideRepo(dir, file) {
98
+ const run = (args) => execFileAsync("git", args, { cwd: dir, encoding: "utf8" })
99
+ .then((r) => r.stdout.trim())
100
+ .catch(() => "");
101
+ const top = await run(["rev-parse", "--show-toplevel"]);
102
+ const common = await run(["rev-parse", "--git-common-dir"]);
103
+ const roots = [top, common && path.resolve(dir, common)].filter(Boolean);
104
+ if (roots.length > 0 && !roots.some((root) => isInside(root, file))) {
105
+ throw new Error(`Refusing to install: core.hooksPath points OUTSIDE this repo (${path.dirname(file)}), ` +
106
+ `so the hook would run for every repo on your machine and upload their diffs. ` +
107
+ `Install per-repo by unsetting the global hooksPath, or point it at an in-repo dir like .husky/.`);
108
+ }
109
+ }
39
110
  export async function installHook(dir) {
40
- const file = await hookPath(dir);
41
- if (fs.existsSync(file)) {
42
- const current = fs.readFileSync(file, "utf8");
43
- if (!current.includes(MARKER)) {
44
- throw new Error(`A post-commit hook already exists at ${file} and it isn't ChangeBook's — not overwriting it. Add this line to it manually if you want both:\n\n` +
45
- hookScript().split("\n").slice(-2).join("\n"));
111
+ // Install each hook independently and report the conflicts together at the
112
+ // end: a foreign pre-commit (Husky, a repo's own guard) must not stop the
113
+ // post-commit analyzer from being installed, and vice versa.
114
+ const problems = [];
115
+ for (const hook of HOOKS) {
116
+ const file = await hookPath(dir, hook.name);
117
+ await assertHookInsideRepo(dir, file);
118
+ if (fs.existsSync(file)) {
119
+ const current = fs.readFileSync(file, "utf8");
120
+ if (!current.includes(hook.marker)) {
121
+ problems.push(`A ${hook.name} hook already exists at ${file} and it isn't ChangeBook's — not overwriting it. Add this to it manually if you want both:\n\n` +
122
+ hook.script().split("\n").slice(4).join("\n"));
123
+ continue;
124
+ }
46
125
  }
126
+ fs.mkdirSync(path.dirname(file), { recursive: true });
127
+ fs.writeFileSync(file, hook.script(), { mode: 0o755 });
128
+ console.error(`✓ ${hook.name} hook installed (${file}).`);
129
+ console.error(hook.installedNote);
47
130
  }
48
- fs.mkdirSync(path.dirname(file), { recursive: true });
49
- fs.writeFileSync(file, hookScript(), { mode: 0o755 });
50
- console.error(`✓ post-commit hook installed (${file}).`);
51
- console.error("Every new commit is analyzed into your atlas in the background (1 credit each; re-runs of the same commit are free).");
131
+ if (problems.length > 0)
132
+ throw new Error(problems.join("\n\n"));
52
133
  }
53
134
  export async function uninstallHook(dir) {
54
- const file = await hookPath(dir);
55
- if (!fs.existsSync(file) || !fs.readFileSync(file, "utf8").includes(MARKER)) {
56
- console.error("No ChangeBook post-commit hook found.");
57
- return;
135
+ for (const hook of HOOKS) {
136
+ const file = await hookPath(dir, hook.name);
137
+ if (!fs.existsSync(file) ||
138
+ !fs.readFileSync(file, "utf8").includes(hook.marker)) {
139
+ console.error(`No ChangeBook ${hook.name} hook found.`);
140
+ continue;
141
+ }
142
+ fs.unlinkSync(file);
143
+ console.error(`✓ ${hook.name} hook removed.`);
58
144
  }
59
- fs.unlinkSync(file);
60
- console.error("✓ post-commit hook removed.");
61
145
  }
62
146
  export async function hookStatus(dir) {
63
- const file = await hookPath(dir);
64
- const installed = fs.existsSync(file) && fs.readFileSync(file, "utf8").includes(MARKER);
65
- console.error(installed
66
- ? `✓ ChangeBook post-commit hook installed (${file}).`
67
- : "No ChangeBook post-commit hook in this repository. Install it with: changebook hook install");
147
+ let missing = false;
148
+ for (const hook of HOOKS) {
149
+ const file = await hookPath(dir, hook.name);
150
+ const installed = fs.existsSync(file) && fs.readFileSync(file, "utf8").includes(hook.marker);
151
+ if (installed) {
152
+ console.error(`✓ ChangeBook ${hook.name} hook installed (${file}).`);
153
+ }
154
+ else {
155
+ missing = true;
156
+ console.error(`✗ No ChangeBook ${hook.name} hook in this repository.`);
157
+ }
158
+ }
159
+ if (missing) {
160
+ console.error("Install the missing hooks with: changebook hook install");
161
+ }
68
162
  }
69
163
  //# sourceMappingURL=hook.js.map