@sdsrs/code-graph 0.161.0 → 0.162.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // Per-session edit log shared by the PreToolUse(Edit) hook (writer) and the
4
+ // Stop hook (reader) — P1 #3.
5
+ //
6
+ // Two files per (project, session), both in the shared cgTmpDir() so the
7
+ // SessionStart prune (24 h, mtime) and `uninstall`'s wholesale removal of that
8
+ // dir cover them without a new cleanup path:
9
+ //
10
+ // .cg-edits-<cwdHash>-<sid>.jsonl one line per Edit call: {ts, file, symbol[, sigs]}
11
+ // .cg-stop-<cwdHash>-<sid>.json written by the Stop hook only:
12
+ // {lastStopAt, reported: ["file#symbol", …]}
13
+ //
14
+ // The edit log is APPEND-only: parallel Edit calls each run their own hook
15
+ // process, and a read-modify-write JSON file would drop one of two concurrent
16
+ // records. A single small `appendFileSync` is one O_APPEND write. The Stop
17
+ // file has exactly one writer (Claude Code runs one Stop per turn), so plain
18
+ // write-then-rename is enough there.
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+ const crypto = require('crypto');
22
+ const { cgTmpDir, cwdHash } = require('./tmp-dir');
23
+
24
+ // Claude Code's session_id is a UUID. Anything else is hashed rather than
25
+ // trusted as a path segment: it is stdin from the harness, and a `../` in it
26
+ // must not be able to name a file outside cgTmpDir().
27
+ function sessionKey(sessionId) {
28
+ const s = String(sessionId || '');
29
+ if (!s) return null;
30
+ if (/^[A-Za-z0-9-]{1,64}$/.test(s)) return s;
31
+ return crypto.createHash('sha1').update(s).digest('hex').slice(0, 16);
32
+ }
33
+
34
+ function editsPath(root, sessionId) {
35
+ const key = sessionKey(sessionId);
36
+ return key ? path.join(cgTmpDir(), `.cg-edits-${cwdHash(root)}-${key}.jsonl`) : null;
37
+ }
38
+
39
+ function stopStatePath(root, sessionId) {
40
+ const key = sessionKey(sessionId);
41
+ return key ? path.join(cgTmpDir(), `.cg-stop-${cwdHash(root)}-${key}.json`) : null;
42
+ }
43
+
44
+ // Baseline capture limits: a larger file, or more same-named definitions than
45
+ // this, records no baseline — and the Stop hook then says nothing about it.
46
+ const MAX_BASELINE_FILE_BYTES = 2 * 1024 * 1024;
47
+ const MAX_BASELINE_SIGS = 32;
48
+
49
+ /**
50
+ * The symbol's definition headers in the file as it is NOW — called from
51
+ * PreToolUse, i.e. before the Edit lands. The Stop hook compares the working
52
+ * tree with the turn's first such record, so "changed this turn" means what it
53
+ * says (review H1). null = no exact reading (stop-impact.js LANGS, size, IO).
54
+ */
55
+ function baselineSignatures(root, file, symbol) {
56
+ try {
57
+ const abs = path.join(root, file);
58
+ if (fs.statSync(abs).size > MAX_BASELINE_FILE_BYTES) return null;
59
+ const { extractSignatures } = require('./stop-impact');
60
+ const sigs = extractSignatures(fs.readFileSync(abs, 'utf8'), symbol, path.extname(file).toLowerCase());
61
+ return sigs && sigs.length <= MAX_BASELINE_SIGS ? sigs : null;
62
+ } catch { return null; }
63
+ }
64
+
65
+ /**
66
+ * Append one Edit to the session log. Best-effort: a failed write costs the
67
+ * Stop check one record, never the Edit it rides on. A record with a symbol
68
+ * also carries `sigs`, that symbol's signatures before this Edit.
69
+ * @param {string} root project root
70
+ * @param {string} sessionId
71
+ * @param {{file: string, symbol?: string|null}} rec file is root-relative
72
+ * @returns {boolean} written
73
+ */
74
+ function recordEdit(root, sessionId, { file, symbol = null }, now = Date.now()) {
75
+ const p = editsPath(root, sessionId);
76
+ if (!p || !file) return false;
77
+ const rec = { ts: now, file, symbol: symbol || null };
78
+ if (symbol) rec.sigs = baselineSignatures(root, file, symbol);
79
+ try {
80
+ fs.appendFileSync(p, JSON.stringify(rec) + '\n');
81
+ return true;
82
+ } catch { return false; }
83
+ }
84
+
85
+ /** Every well-formed record in the session log; a torn or foreign line is skipped. */
86
+ function readEdits(root, sessionId) {
87
+ const p = editsPath(root, sessionId);
88
+ if (!p) return [];
89
+ let raw;
90
+ try { raw = fs.readFileSync(p, 'utf8'); } catch { return []; }
91
+ const out = [];
92
+ for (const line of raw.split('\n')) {
93
+ if (!line) continue;
94
+ try {
95
+ const r = JSON.parse(line);
96
+ if (r && typeof r.ts === 'number' && typeof r.file === 'string') {
97
+ const sigs = Array.isArray(r.sigs) && r.sigs.every((x) => typeof x === 'string') ? r.sigs : null;
98
+ out.push({ ts: r.ts, file: r.file, symbol: typeof r.symbol === 'string' ? r.symbol : null, sigs });
99
+ }
100
+ } catch { /* torn line from a killed writer */ }
101
+ }
102
+ return out;
103
+ }
104
+
105
+ function readStopState(root, sessionId) {
106
+ const p = stopStatePath(root, sessionId);
107
+ const empty = { lastStopAt: null, reported: [] };
108
+ if (!p) return empty;
109
+ try {
110
+ const s = JSON.parse(fs.readFileSync(p, 'utf8'));
111
+ return {
112
+ lastStopAt: typeof s.lastStopAt === 'number' ? s.lastStopAt : null,
113
+ reported: Array.isArray(s.reported) ? s.reported.filter((x) => typeof x === 'string') : [],
114
+ };
115
+ } catch { return empty; }
116
+ }
117
+
118
+ function writeStopState(root, sessionId, state) {
119
+ const p = stopStatePath(root, sessionId);
120
+ if (!p) return false;
121
+ const tmp = `${p}.${process.pid}.tmp`;
122
+ try {
123
+ fs.writeFileSync(tmp, JSON.stringify(state));
124
+ fs.renameSync(tmp, p);
125
+ return true;
126
+ } catch {
127
+ try { fs.unlinkSync(tmp); } catch { /* never created */ }
128
+ return false;
129
+ }
130
+ }
131
+
132
+ module.exports = {
133
+ sessionKey, editsPath, stopStatePath, recordEdit, readEdits, readStopState, writeStopState,
134
+ };
@@ -0,0 +1,431 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // FIRST statement, before the other requires — same reason as pre-edit-guide.js.
4
+ if (require.main === module) require('./hook-fail-open').installHookFailOpen('Stop');
5
+
6
+ // Stop hook (P1 #3b): the end-of-turn half of the edit-impact check.
7
+ //
8
+ // pre-edit-guide.js pushes "this function has N callers" BEFORE an edit, when
9
+ // nobody yet knows whether the edit will change the signature. This runs when
10
+ // the turn ends and asks the question that can be answered then: of the
11
+ // symbols edited this turn, which ones' signatures did THIS TURN change, and
12
+ // which of their callers sit in files this turn did not touch? Those are
13
+ // listed as `file:line`, once per symbol per session, through the Stop
14
+ // event's `additionalContext` — documented as non-error feedback that lets
15
+ // the turn continue, as opposed to `decision: "block"`
16
+ // (https://code.claude.com/docs/en/hooks, "Stop decision control").
17
+ //
18
+ // "Changed this turn" compares the working tree with the signatures
19
+ // pre-edit-guide recorded just before the turn's FIRST Edit of the symbol
20
+ // (session-edits.js `sigs`) — not with HEAD. Both sides of the comparison are
21
+ // then scoped to the same turn: a signature changed in turn 1, with its
22
+ // callers fixed in turn 1, is not re-raised by a body-only edit in turn 2
23
+ // (review H1), and uncommitted work from before the session is not reported
24
+ // as this turn's.
25
+ //
26
+ // Silent when: `stop_hook_active` (a continuation this or another Stop hook
27
+ // caused — never loop), no session edit log, no index, not a git work tree,
28
+ // no signature changed, the language has no exact header reading (LANGS),
29
+ // every caller's file was touched this turn, or the symbol was already
30
+ // reported this session. CODE_GRAPH_QUIET_HOOKS=1 silences it like the other
31
+ // injecting hooks.
32
+ //
33
+ // "Touched this turn" = an Edit to the file was logged since the previous Stop,
34
+ // OR the file's mtime is at or after the turn's start (covers Write, Bash
35
+ // `sed -i`, formatters). The turn starts at the previous Stop; the first turn
36
+ // of a session starts at its first logged Edit. Both only ever SUPPRESS a
37
+ // line, so an over-wide "touched" set costs a missed report, never a wrong one.
38
+ const { execFileSync } = require('child_process');
39
+ const fs = require('fs');
40
+ const path = require('path');
41
+ const { hidden } = require('./proc-opts');
42
+ const { shellQuoteArg, formatCgCommand } = require('./cg-answer');
43
+
44
+ // Per-Stop work caps. The hook blocks the end of the turn, so its worst case
45
+ // is bounded by count as well as by the registered budget. The symbol cap
46
+ // bounds the `refs` queries, so it applies AFTER the text-only "did the
47
+ // signature change" filter (review M1): body-only edits do not use it up.
48
+ const MAX_SYMBOLS_CHECKED = 8;
49
+ const MAX_CALLERS_LISTED = 8;
50
+ // A header that has not ended within this many characters is not read at all
51
+ // (no verdict), rather than cut: a cut header can run into the body, and a
52
+ // body edit would then read as a signature change.
53
+ const MAX_SIGNATURE_CHARS = 600;
54
+ // A match on a line longer than this gives no reading (null): minified and
55
+ // generated one-line files. Re-finding the line per match made such a file
56
+ // quadratic (pre-tag review H2: a 1.5 MB one-line bundle took 9 s, past
57
+ // pre-edit-guide's 4 s budget); with the cap a 2 MB file of 1,999-character
58
+ // lines of definitions takes about 0.2 s.
59
+ const MAX_DEFINITION_LINE_CHARS = 2000;
60
+
61
+ const COMMENT_LINE = /^\s*(?:\/\/|#|\*|\/\*|--|;)/;
62
+ // First words that make a C-family `Type name(` line a statement, not a
63
+ // definition.
64
+ const NOT_A_TYPE = new Set([
65
+ 'return', 'new', 'throw', 'else', 'await', 'yield', 'case', 'delete', 'typeof',
66
+ 'echo', 'print', 'puts', 'if', 'while', 'for', 'switch', 'catch', 'when', 'not',
67
+ 'and', 'or', 'in', 'is', 'of', 'do', 'assert', 'raise', 'import', 'from', 'use',
68
+ 'let', 'const', 'var', 'mut', 'match', 'go', 'defer', 'sizeof', 'co_return',
69
+ 'co_await', 'co_yield', 'default', 'goto',
70
+ ]);
71
+
72
+ // How each language's definition headers are found and where they end. Only
73
+ // languages whose headers these rules read exactly are listed; every other
74
+ // extension gets no verdict, and the Stop hook says nothing about its symbols
75
+ // (review M2: silent beats wrong).
76
+ //
77
+ // keyword a definition is `<keyword> name` (`receiver`: Go's `func (r T) name`;
78
+ // `typeParams`: Kotlin's `fun <T> name`)
79
+ // jsForms also `const name = (…) =>` bindings and one-line class methods
80
+ // typed C-family `Type name(` lines — no keyword exists
81
+ // ends characters that end the header at bracket depth 0; `\n` included
82
+ // only where a newline outside brackets cannot continue a header
83
+ // arrowEnds `=>` at depth 0 ends it (JS arrow bodies, C# expression bodies);
84
+ // elsewhere `=>` is part of a type (Scala) and never an end
85
+ // angles `<…>` nests like a bracket (TS generics may hold `{`)
86
+ // afterParams a lone `:` after the parameter list ends it (C++ member
87
+ // initializers, C# `: base(…)` — body, not signature)
88
+ // closeParen the parameter list's `)` ends it (Ruby, Lua: no return types,
89
+ // and a one-line body may follow on the same line)
90
+ const LANGS = new Map();
91
+ function defineLang(exts, profile) {
92
+ for (const e of exts) LANGS.set(e, profile);
93
+ }
94
+ defineLang(['.rs'], { keyword: 'fn', ends: '{;' });
95
+ defineLang(['.py', '.pyi'], { keyword: 'def', ends: ':' });
96
+ defineLang(['.go'], { keyword: 'func', receiver: true, ends: '{;\n' });
97
+ defineLang(['.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx', '.mts', '.cts'],
98
+ { keyword: 'function\\*?', jsForms: true, ends: '{;', arrowEnds: true, angles: true });
99
+ defineLang(['.php'], { keyword: 'function', ends: '{;' });
100
+ defineLang(['.c', '.h', '.cc', '.cpp', '.cxx', '.hpp', '.hh', '.hxx', '.java', '.cs'],
101
+ { typed: true, ends: '{;', arrowEnds: true, afterParams: true });
102
+ defineLang(['.kt', '.kts'], { keyword: 'fun', typeParams: true, ends: '{=\n' });
103
+ defineLang(['.scala', '.sc'], { keyword: 'def', ends: '{=\n' });
104
+ defineLang(['.rb'], { keyword: 'def', ends: ';=\n', closeParen: true });
105
+ defineLang(['.lua'], { keyword: 'function', ends: '\n', closeParen: true });
106
+
107
+ function escapeRe(s) {
108
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
109
+ }
110
+
111
+ function definitionPatterns(S, lang) {
112
+ const out = [];
113
+ if (lang.keyword) {
114
+ const receiver = lang.receiver ? '(?:\\([^)\\n]*\\)\\s*)?' : '';
115
+ const typeParams = lang.typeParams ? '(?:<[^>\\n]*>\\s*)?' : '';
116
+ out.push({ re: new RegExp(`\\b(?:${lang.keyword})\\s+${receiver}${typeParams}${S}\\b`, 'g') });
117
+ }
118
+ if (lang.jsForms) {
119
+ out.push({ re: new RegExp(`\\b(?:const|let|var)\\s+${S}\\s*=\\s*(?:async\\s+)?(?:function\\b|\\([^)\\n]*\\)\\s*=>|[A-Za-z_$][\\w$]*\\s*=>)`, 'g') });
120
+ out.push({ re: new RegExp(`^[ \\t]*(?:(?:async|static|get|set|public|private|protected|readonly|override)\\s+)*\\*?${S}\\s*\\([^()\\n]*\\)\\s*(?::\\s*[^{;=\\n]+)?\\{[ \\t]*$`, 'gm') });
121
+ }
122
+ if (lang.typed) {
123
+ out.push({ re: new RegExp(`^[ \\t]*((?:[A-Za-z_][\\w<>\\[\\],.*&:?]*[ \\t]+)+)${S}[ \\t]*\\(`, 'gm'), typed: true });
124
+ }
125
+ return out;
126
+ }
127
+
128
+ /**
129
+ * Normalized signatures of every definition of `symbol` in `text`, sorted —
130
+ * or null when there is no exact reading: the language is not in LANGS, a
131
+ * header did not end within MAX_SIGNATURE_CHARS, or a match of a definition
132
+ * pattern sits on a line longer than MAX_DEFINITION_LINE_CHARS.
133
+ *
134
+ * A definition is located by the line it starts on (LANGS) and runs from that
135
+ * line's start to the first end character at bracket depth 0. The key drops
136
+ * all whitespace and a trailing comma, so a formatter re-wrapping the
137
+ * parameter list is not a change.
138
+ * @returns {string[]|null}
139
+ */
140
+ function extractSignatures(text, symbol, ext = '') {
141
+ const lang = LANGS.get(String(ext).toLowerCase());
142
+ if (!lang) return null;
143
+ if (typeof text !== 'string' || !symbol) return [];
144
+ const lineStarts = new Set();
145
+ for (const { re, typed } of definitionPatterns(escapeRe(symbol), lang)) {
146
+ for (const m of text.matchAll(re)) {
147
+ const at = text.lastIndexOf('\n', m.index) + 1;
148
+ const lineEnd = text.indexOf('\n', at);
149
+ const line = text.slice(at, lineEnd === -1 ? text.length : lineEnd);
150
+ // Checked first: finding the line is the per-match cost, so a match on
151
+ // a long line must end the scan, or a one-line bundle is quadratic.
152
+ if (line.length > MAX_DEFINITION_LINE_CHARS) return null;
153
+ if (COMMENT_LINE.test(line)) continue;
154
+ if (typed) {
155
+ const first = (line.trim().match(/^[A-Za-z_]\w*/) || [''])[0];
156
+ if (NOT_A_TYPE.has(first)) continue;
157
+ // `label: f(`, `case X: f(`, `public: f(` — a token ending in a lone
158
+ // `:` is a label or access specifier, never part of a return type.
159
+ if (m[1].split(/[ \t]+/).some((tok) => /(?:^|[^:]):$/.test(tok))) continue;
160
+ }
161
+ lineStarts.add(at);
162
+ }
163
+ }
164
+ const out = [];
165
+ for (const at of lineStarts) {
166
+ const header = readHeader(text, at, lang);
167
+ if (header === null) return null;
168
+ out.push(signatureKey(header));
169
+ }
170
+ return out.sort();
171
+ }
172
+
173
+ function readHeader(text, at, lang) {
174
+ let depth = 0;
175
+ let angle = 0;
176
+ let sawParams = false;
177
+ const limit = Math.min(text.length, at + MAX_SIGNATURE_CHARS);
178
+ for (let i = at; i < limit; i++) {
179
+ const c = text[i];
180
+ if (c === '=' && text[i + 1] === '>') {
181
+ if (lang.arrowEnds && depth === 0 && angle === 0) return text.slice(at, i);
182
+ i++; // part of a type or a default value: step over both characters
183
+ continue;
184
+ }
185
+ if (c === '(' || c === '[') { depth++; continue; }
186
+ if (c === ')' || c === ']') {
187
+ depth = Math.max(0, depth - 1);
188
+ if (c === ')' && depth === 0) {
189
+ sawParams = true;
190
+ if (lang.closeParen) return text.slice(at, i + 1);
191
+ }
192
+ continue;
193
+ }
194
+ if (depth !== 0) continue;
195
+ if (lang.angles && c === '<') { angle++; continue; }
196
+ if (lang.angles && c === '>') { angle = Math.max(0, angle - 1); continue; }
197
+ if (angle !== 0) continue;
198
+ if (lang.ends.includes(c) && !(c === '\n' && i === at)) return text.slice(at, i);
199
+ if (lang.afterParams && sawParams && c === ':' && text[i - 1] !== ':' && text[i + 1] !== ':') {
200
+ return text.slice(at, i);
201
+ }
202
+ }
203
+ // End of text ends a header; running out of the character budget does not.
204
+ return limit === text.length ? text.slice(at, limit) : null;
205
+ }
206
+
207
+ function signatureKey(header) {
208
+ return header.replace(/\s+/g, '').replace(/,\)/g, ')');
209
+ }
210
+
211
+ /**
212
+ * True when a definition that existed before is gone: its header changed, or
213
+ * it was removed while others of the same name remain. A definition ADDED
214
+ * beside unchanged ones (a new overload, a new `impl From<B>`) changes no
215
+ * existing caller's target, so it is not a change (review M2).
216
+ */
217
+ function signatureChanged(oldSigs, newSigs) {
218
+ const remaining = new Map();
219
+ for (const s of newSigs) remaining.set(s, (remaining.get(s) || 0) + 1);
220
+ for (const s of oldSigs) {
221
+ const n = remaining.get(s) || 0;
222
+ if (n === 0) return true;
223
+ remaining.set(s, n - 1);
224
+ }
225
+ return false;
226
+ }
227
+
228
+ /**
229
+ * The line of the first mention of `symbol` at or after the caller's own
230
+ * start line — the call site, which is what a reader needs to open. Falls
231
+ * back to the caller's start line.
232
+ */
233
+ function findCallSiteLine(text, symbol, startLine) {
234
+ const start = Math.max(1, Number(startLine) || 1);
235
+ if (typeof text !== 'string') return start;
236
+ const lines = text.split('\n');
237
+ const re = new RegExp(`(?:^|[^\\w$])${escapeRe(symbol)}(?![\\w$])`);
238
+ for (let i = start - 1; i < lines.length && i < start - 1 + 400; i++) {
239
+ if (!COMMENT_LINE.test(lines[i]) && re.test(lines[i])) return i + 1;
240
+ }
241
+ return start;
242
+ }
243
+
244
+ /**
245
+ * The decision, with every side effect injected (unit-testable without git or
246
+ * a binary). Returns the lines to report and the next Stop state.
247
+ *
248
+ * @param {object} a
249
+ * @param {{ts:number,file:string,symbol:string|null,sigs:string[]|null}[]} a.edits
250
+ * whole session log; `sigs` = the symbol's signatures just before that Edit
251
+ * @param {{lastStopAt:number|null, reported:string[]}} a.state
252
+ * @param {number} a.now
253
+ * @param {(file:string)=>string|null} a.workText file in the working tree
254
+ * @param {(symbol:string,file:string)=>({file:string,line:number,name:string}[]|null)} a.callers
255
+ * @param {(file:string)=>number|null} a.mtimeMs
256
+ */
257
+ function computeStopReport({ edits, state, now, workText, callers, mtimeMs }) {
258
+ const lastStopAt = state.lastStopAt;
259
+ const turn = edits.filter((e) => lastStopAt === null || e.ts > lastStopAt);
260
+ const next = { lastStopAt: now, reported: [...state.reported] };
261
+ if (turn.length === 0) return { lines: [], state: next };
262
+
263
+ const turnStart = lastStopAt !== null ? lastStopAt : Math.min(...edits.map((e) => e.ts));
264
+ const editedThisTurn = new Set(turn.map((e) => e.file));
265
+ const touched = (file) => {
266
+ if (editedThisTurn.has(file)) return true;
267
+ const m = mtimeMs(file);
268
+ return m !== null && m >= turnStart;
269
+ };
270
+
271
+ // One candidate per (file, symbol). Its baseline is what pre-edit-guide
272
+ // recorded before the turn's FIRST Edit of it — the signature as the turn
273
+ // found it. Later records of the same symbol this turn were taken after
274
+ // earlier edits had landed, so they are not the baseline.
275
+ const seen = new Set();
276
+ const changed = [];
277
+ for (const e of turn) {
278
+ if (!e.symbol) continue;
279
+ const key = `${e.file}#${e.symbol}`;
280
+ if (seen.has(key)) continue;
281
+ seen.add(key);
282
+ if (next.reported.includes(key)) continue;
283
+ // null: no exact reading (language, unreadable file, a pre-repair log
284
+ // line). [] (the definition was added this turn) needs no test of its
285
+ // own: signatureChanged counts only definitions that went missing.
286
+ if (!Array.isArray(e.sigs)) continue;
287
+ const after = extractSignatures(workText(e.file), e.symbol, path.extname(e.file).toLowerCase());
288
+ if (!after || after.length === 0) continue; // removed, or no reading now
289
+ if (!signatureChanged(e.sigs, after)) continue; // body-only this turn
290
+ changed.push({ key, file: e.file, symbol: e.symbol });
291
+ }
292
+
293
+ const lines = [];
294
+ for (const c of changed.slice(0, MAX_SYMBOLS_CHECKED)) {
295
+ const refs = callers(c.symbol, c.file);
296
+ if (!refs || refs.length === 0) continue;
297
+ const untouched = [];
298
+ const dedup = new Set();
299
+ for (const r of refs) {
300
+ if (r.file === c.file || touched(r.file)) continue;
301
+ const id = `${r.file}:${r.line}`;
302
+ if (dedup.has(id)) continue;
303
+ dedup.add(id);
304
+ untouched.push(r);
305
+ }
306
+ if (untouched.length === 0) continue;
307
+ untouched.sort((x, y) => (x.file < y.file ? -1 : x.file > y.file ? 1 : x.line - y.line));
308
+ // Every repo-derived token is shell-quoted (review M3): a file name is
309
+ // text from the repository, and the model may paste it into a command.
310
+ const shown = untouched.slice(0, MAX_CALLERS_LISTED).map((r) => {
311
+ const name = r.name && r.name !== '<module>' ? ' (' + shellQuoteArg(r.name) + ')' : '';
312
+ return shellQuoteArg(r.file) + ':' + r.line + name;
313
+ });
314
+ let line = ' ' + shellQuoteArg(c.symbol) + '() in ' + shellQuoteArg(c.file) + ': ' + shown.join(', ');
315
+ if (untouched.length > MAX_CALLERS_LISTED) {
316
+ line += ', and ' + (untouched.length - MAX_CALLERS_LISTED) + ' more (' +
317
+ formatCgCommand(['refs', c.symbol, '--file', c.file]) + ')';
318
+ }
319
+ lines.push(line);
320
+ next.reported.push(c.key);
321
+ }
322
+ // Changed beyond the query cap: named, not dropped in silence (review M1).
323
+ // Not marked reported, and a later turn does not see them as changed again
324
+ // (their baseline moves on), so this line is the only mention they get.
325
+ const unchecked = changed.slice(MAX_SYMBOLS_CHECKED);
326
+ if (unchecked.length > 0) {
327
+ lines.push(' ' + unchecked.length + ' more changed, callers not checked: ' +
328
+ unchecked.map((c) => shellQuoteArg(c.symbol) + '() in ' + shellQuoteArg(c.file)).join(', '));
329
+ }
330
+ return { lines, state: next };
331
+ }
332
+
333
+ function formatStopContext(lines) {
334
+ if (!lines.length) return null;
335
+ // No apostrophe in the fixed text: file names are single-quoted in it.
336
+ return '[code-graph] Signatures changed this turn (each compared with its file before ' +
337
+ 'the first edit of it this turn); their callers in files not edited this turn, from the code-graph index:\n' +
338
+ lines.join('\n') + '\n';
339
+ }
340
+
341
+ // --- Side-effecting shell ---------------------------------------------------
342
+
343
+ function readStdinJson() {
344
+ try {
345
+ // fd 0, not '/dev/stdin' (ENXIO on socketpair stdin) — as the other hooks.
346
+ return JSON.parse(fs.readFileSync(0, 'utf8'));
347
+ } catch { return null; }
348
+ }
349
+
350
+ function runMain() {
351
+ if (process.env.CODE_GRAPH_QUIET_HOOKS === '1') return;
352
+ const input = readStdinJson();
353
+ if (!input || input.stop_hook_active === true) return;
354
+ if (!input.session_id) return;
355
+
356
+ const { resolveProjectRoot } = require('./project-root');
357
+ const root = resolveProjectRoot(typeof input.cwd === 'string' && input.cwd ? input.cwd : process.cwd());
358
+ if (root === null) return;
359
+
360
+ const sessionEdits = require('./session-edits');
361
+ const edits = sessionEdits.readEdits(root, input.session_id);
362
+ if (edits.length === 0) return; // no log → nothing to check, and nothing written
363
+ const state = sessionEdits.readStopState(root, input.session_id);
364
+
365
+ const { remainingMs } = require('./hook-fail-open');
366
+ const run = (cmd, args, defaultMs) => {
367
+ const budget = remainingMs(defaultMs);
368
+ if (budget === null) return null;
369
+ try {
370
+ return execFileSync(cmd, args, hidden({
371
+ cwd: root,
372
+ timeout: budget,
373
+ encoding: 'utf8',
374
+ stdio: ['ignore', 'pipe', 'ignore'],
375
+ env: { ...process.env, CODE_GRAPH_INTERNAL: '1' },
376
+ maxBuffer: 16 * 1024 * 1024,
377
+ }));
378
+ } catch { return null; }
379
+ };
380
+
381
+ // The comparison no longer reads HEAD (baselines come from the edit log),
382
+ // but "silent outside a git work tree" is the spec's contract for this
383
+ // hook, so the gate stays.
384
+ const inGit = run('git', ['rev-parse', '--is-inside-work-tree'], 1000);
385
+ let binary = null;
386
+ let report = { lines: [], state: { lastStopAt: Date.now(), reported: state.reported } };
387
+ if (inGit !== null && inGit.trim() === 'true') {
388
+ binary = require('./find-binary').findBinary();
389
+ }
390
+ if (binary) {
391
+ report = computeStopReport({
392
+ edits,
393
+ state,
394
+ now: Date.now(),
395
+ workText: (file) => {
396
+ try { return fs.readFileSync(path.join(root, file), 'utf8'); } catch { return null; }
397
+ },
398
+ callers: (symbol, file) => {
399
+ const raw = run(binary, ['refs', symbol, '--file', file, '--relation', 'calls',
400
+ '--min-confidence', 'inferred', '--json'], 1500);
401
+ if (raw === null) return null;
402
+ let parsed;
403
+ try { parsed = JSON.parse(raw); } catch { return null; }
404
+ if (!parsed || !Array.isArray(parsed.references)) return null;
405
+ return parsed.references
406
+ .filter((r) => r && typeof r.file_path === 'string')
407
+ .map((r) => {
408
+ let text = null;
409
+ try { text = fs.readFileSync(path.join(root, r.file_path), 'utf8'); } catch { /* gone */ }
410
+ return { file: r.file_path, name: r.name, line: findCallSiteLine(text, symbol, r.start_line) };
411
+ });
412
+ },
413
+ mtimeMs: (file) => {
414
+ try { return fs.statSync(path.join(root, file)).mtimeMs; } catch { return null; }
415
+ },
416
+ });
417
+ }
418
+ sessionEdits.writeStopState(root, input.session_id, report.state);
419
+
420
+ const text = formatStopContext(report.lines);
421
+ if (!text) return;
422
+ const { emitEventContext } = require('./hook-emit');
423
+ process.stdout.write(emitEventContext('Stop', text) + '\n');
424
+ }
425
+
426
+ if (require.main === module) runMain();
427
+
428
+ module.exports = {
429
+ extractSignatures, signatureChanged, findCallSiteLine, computeStopReport, formatStopContext,
430
+ MAX_SYMBOLS_CHECKED, MAX_CALLERS_LISTED,
431
+ };
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // FIRST statement, before the other requires — same reason as pre-edit-guide.js.
4
+ if (require.main === module) require('./hook-fail-open').installHookFailOpen('SubagentStart');
5
+
6
+ // SubagentStart hook (P1 #3a), registered by lifecycle.js with the matcher
7
+ // `Explore|Plan|general-purpose`.
8
+ //
9
+ // Explore and Plan subagents do not load CLAUDE.md, so the adopt block that
10
+ // tells the main session about the index never reaches them, and they fall
11
+ // back to multi-round grep/Read for structural questions. This hands them the
12
+ // same facts at spawn: the repo has an index, how big and how fresh, and the
13
+ // three CLI commands that answer structural questions from it.
14
+ //
15
+ // Facts only — no "you must". The hooks reference says imperative text framed
16
+ // as out-of-band instructions can trip Claude's prompt-injection defenses
17
+ // (https://code.claude.com/docs/en/hooks, "Add context for Claude").
18
+ //
19
+ // Silent when: no index up the tree, no binary (the commands would not run),
20
+ // the health check fails or reports 0 files, or CODE_GRAPH_QUIET_HOOKS=1 (the
21
+ // same switch that skips the SessionStart project-map injection).
22
+ const { execFileSync } = require('child_process');
23
+ const fs = require('fs');
24
+ const { hidden } = require('./proc-opts');
25
+
26
+ // Hard ceiling on the injected text, in characters (spec: ≤400). Every
27
+ // subagent spawn pays it in context, and a subagent's context is small.
28
+ const MAX_CONTEXT_CHARS = 400;
29
+
30
+ /**
31
+ * The text a subagent receives, or null when there is nothing true to say.
32
+ * @param {{files?: number, index_age?: string, index_version_stale?: boolean}} report
33
+ * `health-check --format json` output
34
+ * @param {number} [maxChars] ceiling; a parameter only so tests can reach the
35
+ * check — the builder's longest output (389) is below the real one
36
+ * @returns {string|null}
37
+ */
38
+ function buildSubagentContext(report, maxChars = MAX_CONTEXT_CHARS) {
39
+ if (!report || typeof report !== 'object') return null;
40
+ const files = Number(report.files);
41
+ if (!Number.isFinite(files) || files <= 0) return null;
42
+ const age = typeof report.index_age === 'string' && /^[\w .-]{1,24}$/.test(report.index_age)
43
+ ? report.index_age : null;
44
+ let text = `This repository has a code-graph AST index of ${files} files`;
45
+ text += age ? `, last updated ${age}.` : '.';
46
+ if (report.index_version_stale === true) text += ' A rebuild for a newer extractor is pending.';
47
+ text += ' Structural questions are answered from it through Bash:' +
48
+ ' `code-graph-mcp callgraph <fn>` (callers and callees),' +
49
+ ' `code-graph-mcp show <fn>` (a symbol\'s source and signature),' +
50
+ ' `code-graph-mcp overview <dir>` (symbols of a module grouped by file).';
51
+ return text.length <= maxChars ? text : null;
52
+ }
53
+
54
+ function readStdinJson() {
55
+ try {
56
+ // fd 0, not '/dev/stdin' (ENXIO on socketpair stdin) — as the other hooks.
57
+ return JSON.parse(fs.readFileSync(0, 'utf8'));
58
+ } catch { return null; }
59
+ }
60
+
61
+ function runMain() {
62
+ if (process.env.CODE_GRAPH_QUIET_HOOKS === '1') return;
63
+ const input = readStdinJson();
64
+ if (!input) return;
65
+
66
+ const { resolveProjectRoot } = require('./project-root');
67
+ const root = resolveProjectRoot(typeof input.cwd === 'string' && input.cwd ? input.cwd : process.cwd());
68
+ if (root === null) return;
69
+
70
+ const binary = require('./find-binary').findBinary();
71
+ if (!binary) return;
72
+
73
+ // Spend the registered budget, not a literal (hooks.test.js "armed must spend").
74
+ const budget = require('./hook-fail-open').remainingMs(1500);
75
+ if (budget === null) return;
76
+ let report;
77
+ try {
78
+ report = JSON.parse(execFileSync(binary, ['health-check', '--format', 'json'], hidden({
79
+ cwd: root,
80
+ timeout: budget,
81
+ encoding: 'utf8',
82
+ stdio: ['ignore', 'pipe', 'ignore'],
83
+ env: { ...process.env, CODE_GRAPH_INTERNAL: '1' },
84
+ })));
85
+ } catch { return; }
86
+
87
+ const text = buildSubagentContext(report);
88
+ if (!text) return;
89
+ const { emitEventContext } = require('./hook-emit');
90
+ process.stdout.write(emitEventContext('SubagentStart', text) + '\n');
91
+ }
92
+
93
+ if (require.main === module) runMain();
94
+
95
+ module.exports = { buildSubagentContext, MAX_CONTEXT_CHARS };
@@ -35,7 +35,7 @@ jobs:
35
35
  node-version: '20'
36
36
  - name: Build snapshot
37
37
  run: |
38
- npx -y -p @sdsrs/code-graph@0.161.0 code-graph-mcp snapshot create --out snapshot.db
38
+ npx -y -p @sdsrs/code-graph@0.162.0 code-graph-mcp snapshot create --out snapshot.db
39
39
  zstd -9 snapshot.db -o snapshot.db.zst
40
40
  mv snapshot.db.zst "code-graph-snapshot-${GITHUB_SHA:0:7}.db.zst"
41
41
  - name: Upload to release