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 +5 -2
- package/dist/analyze.js +26 -4
- package/dist/canonical.js +44 -0
- package/dist/git.js +34 -0
- package/dist/guard.js +276 -0
- package/dist/hook.js +128 -34
- package/dist/import.js +32 -7
- package/dist/index.js +30 -4
- package/dist/supabase.js +114 -0
- package/dist/sync.js +100 -14
- package/dist/tools.js +146 -9
- package/package.json +1 -1
- package/server.json +2 -2
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
|
|
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
|
|
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
|
-
|
|
94
|
-
|
|
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 {
|
|
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]` —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
${
|
|
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(
|
|
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
|
-
|
|
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
|
|
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",
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|