@sdsrs/code-graph 0.161.0 → 0.163.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,141 @@
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
+ // pending?: {at, files: […]}} — the last
14
+ // report's caller files, until its follow-up
15
+ //
16
+ // The edit log is APPEND-only: parallel Edit calls each run their own hook
17
+ // process, and a read-modify-write JSON file would drop one of two concurrent
18
+ // records. A single small `appendFileSync` is one O_APPEND write. The Stop
19
+ // file has exactly one writer (Claude Code runs one Stop per turn), so plain
20
+ // write-then-rename is enough there.
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+ const crypto = require('crypto');
24
+ const { cgTmpDir, cwdHash } = require('./tmp-dir');
25
+
26
+ // Claude Code's session_id is a UUID. Anything else is hashed rather than
27
+ // trusted as a path segment: it is stdin from the harness, and a `../` in it
28
+ // must not be able to name a file outside cgTmpDir().
29
+ function sessionKey(sessionId) {
30
+ const s = String(sessionId || '');
31
+ if (!s) return null;
32
+ if (/^[A-Za-z0-9-]{1,64}$/.test(s)) return s;
33
+ return crypto.createHash('sha1').update(s).digest('hex').slice(0, 16);
34
+ }
35
+
36
+ function editsPath(root, sessionId) {
37
+ const key = sessionKey(sessionId);
38
+ return key ? path.join(cgTmpDir(), `.cg-edits-${cwdHash(root)}-${key}.jsonl`) : null;
39
+ }
40
+
41
+ function stopStatePath(root, sessionId) {
42
+ const key = sessionKey(sessionId);
43
+ return key ? path.join(cgTmpDir(), `.cg-stop-${cwdHash(root)}-${key}.json`) : null;
44
+ }
45
+
46
+ // Baseline capture limits: a larger file, or more same-named definitions than
47
+ // this, records no baseline — and the Stop hook then says nothing about it.
48
+ const MAX_BASELINE_FILE_BYTES = 2 * 1024 * 1024;
49
+ const MAX_BASELINE_SIGS = 32;
50
+
51
+ /**
52
+ * The symbol's definition headers in the file as it is NOW — called from
53
+ * PreToolUse, i.e. before the Edit lands. The Stop hook compares the working
54
+ * tree with the turn's first such record, so "changed this turn" means what it
55
+ * says (review H1). null = no exact reading (stop-impact.js LANGS, size, IO).
56
+ */
57
+ function baselineSignatures(root, file, symbol) {
58
+ try {
59
+ const abs = path.join(root, file);
60
+ if (fs.statSync(abs).size > MAX_BASELINE_FILE_BYTES) return null;
61
+ const { extractSignatures } = require('./stop-impact');
62
+ const sigs = extractSignatures(fs.readFileSync(abs, 'utf8'), symbol, path.extname(file).toLowerCase());
63
+ return sigs && sigs.length <= MAX_BASELINE_SIGS ? sigs : null;
64
+ } catch { return null; }
65
+ }
66
+
67
+ /**
68
+ * Append one Edit to the session log. Best-effort: a failed write costs the
69
+ * Stop check one record, never the Edit it rides on. A record with a symbol
70
+ * also carries `sigs`, that symbol's signatures before this Edit.
71
+ * @param {string} root project root
72
+ * @param {string} sessionId
73
+ * @param {{file: string, symbol?: string|null}} rec file is root-relative
74
+ * @returns {boolean} written
75
+ */
76
+ function recordEdit(root, sessionId, { file, symbol = null }, now = Date.now()) {
77
+ const p = editsPath(root, sessionId);
78
+ if (!p || !file) return false;
79
+ const rec = { ts: now, file, symbol: symbol || null };
80
+ if (symbol) rec.sigs = baselineSignatures(root, file, symbol);
81
+ try {
82
+ fs.appendFileSync(p, JSON.stringify(rec) + '\n');
83
+ return true;
84
+ } catch { return false; }
85
+ }
86
+
87
+ /** Every well-formed record in the session log; a torn or foreign line is skipped. */
88
+ function readEdits(root, sessionId) {
89
+ const p = editsPath(root, sessionId);
90
+ if (!p) return [];
91
+ let raw;
92
+ try { raw = fs.readFileSync(p, 'utf8'); } catch { return []; }
93
+ const out = [];
94
+ for (const line of raw.split('\n')) {
95
+ if (!line) continue;
96
+ try {
97
+ const r = JSON.parse(line);
98
+ if (r && typeof r.ts === 'number' && typeof r.file === 'string') {
99
+ const sigs = Array.isArray(r.sigs) && r.sigs.every((x) => typeof x === 'string') ? r.sigs : null;
100
+ out.push({ ts: r.ts, file: r.file, symbol: typeof r.symbol === 'string' ? r.symbol : null, sigs });
101
+ }
102
+ } catch { /* torn line from a killed writer */ }
103
+ }
104
+ return out;
105
+ }
106
+
107
+ function readStopState(root, sessionId) {
108
+ const p = stopStatePath(root, sessionId);
109
+ const empty = { lastStopAt: null, reported: [] };
110
+ if (!p) return empty;
111
+ try {
112
+ const s = JSON.parse(fs.readFileSync(p, 'utf8'));
113
+ const out = {
114
+ lastStopAt: typeof s.lastStopAt === 'number' ? s.lastStopAt : null,
115
+ reported: Array.isArray(s.reported) ? s.reported.filter((x) => typeof x === 'string') : [],
116
+ };
117
+ const p2 = s.pending;
118
+ if (p2 && typeof p2.at === 'number' && Array.isArray(p2.files)) {
119
+ out.pending = { at: p2.at, files: p2.files.filter((x) => typeof x === 'string') };
120
+ }
121
+ return out;
122
+ } catch { return empty; }
123
+ }
124
+
125
+ function writeStopState(root, sessionId, state) {
126
+ const p = stopStatePath(root, sessionId);
127
+ if (!p) return false;
128
+ const tmp = `${p}.${process.pid}.tmp`;
129
+ try {
130
+ fs.writeFileSync(tmp, JSON.stringify(state));
131
+ fs.renameSync(tmp, p);
132
+ return true;
133
+ } catch {
134
+ try { fs.unlinkSync(tmp); } catch { /* never created */ }
135
+ return false;
136
+ }
137
+ }
138
+
139
+ module.exports = {
140
+ sessionKey, editsPath, stopStatePath, recordEdit, readEdits, readStopState, writeStopState,
141
+ };
@@ -0,0 +1,496 @@
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
+ const listed = new Set();
295
+ for (const c of changed.slice(0, MAX_SYMBOLS_CHECKED)) {
296
+ const refs = callers(c.symbol, c.file);
297
+ if (!refs || refs.length === 0) continue;
298
+ const untouched = [];
299
+ const dedup = new Set();
300
+ for (const r of refs) {
301
+ if (r.file === c.file || touched(r.file)) continue;
302
+ const id = `${r.file}:${r.line}`;
303
+ if (dedup.has(id)) continue;
304
+ dedup.add(id);
305
+ untouched.push(r);
306
+ }
307
+ if (untouched.length === 0) continue;
308
+ untouched.sort((x, y) => (x.file < y.file ? -1 : x.file > y.file ? 1 : x.line - y.line));
309
+ // Every repo-derived token is shell-quoted (review M3): a file name is
310
+ // text from the repository, and the model may paste it into a command.
311
+ const shown = untouched.slice(0, MAX_CALLERS_LISTED).map((r) => {
312
+ const name = r.name && r.name !== '<module>' ? ' (' + shellQuoteArg(r.name) + ')' : '';
313
+ return shellQuoteArg(r.file) + ':' + r.line + name;
314
+ });
315
+ let line = ' ' + shellQuoteArg(c.symbol) + '() in ' + shellQuoteArg(c.file) + ': ' + shown.join(', ');
316
+ if (untouched.length > MAX_CALLERS_LISTED) {
317
+ line += ', and ' + (untouched.length - MAX_CALLERS_LISTED) + ' more (' +
318
+ formatCgCommand(['refs', c.symbol, '--file', c.file]) + ')';
319
+ }
320
+ lines.push(line);
321
+ next.reported.push(c.key);
322
+ for (const r of untouched) listed.add(r.file);
323
+ }
324
+ // Changed beyond the query cap: named, not dropped in silence (review M1).
325
+ // Not marked reported, and a later turn does not see them as changed again
326
+ // (their baseline moves on), so this line is the only mention they get.
327
+ const unchecked = changed.slice(MAX_SYMBOLS_CHECKED);
328
+ if (unchecked.length > 0) {
329
+ lines.push(' ' + unchecked.length + ' more changed, callers not checked: ' +
330
+ unchecked.map((c) => shellQuoteArg(c.symbol) + '() in ' + shellQuoteArg(c.file)).join(', '));
331
+ }
332
+ // `symbols` / `files`: what the report names, for its telemetry and its
333
+ // follow-up (D#163). Not part of `state`.
334
+ return { lines, state: next, symbols: next.reported.length - state.reported.length, files: [...listed].sort() };
335
+ }
336
+
337
+ // The follow-up remembers at most this many of a report's caller files.
338
+ const MAX_FOLLOWUP_FILES = 64;
339
+
340
+ /**
341
+ * What a report leaves behind (D#163): the `stop_check` record — for every
342
+ * report the model is shown, the one made only of the "more changed, callers
343
+ * not checked" line included — and the follow-up to judge at the next Stop,
344
+ * only when the report named caller files. `callers` counts every caller file
345
+ * the report found, the ones past the text's per-symbol cap included.
346
+ * @param {{lines:string[], state:{lastStopAt:number}, symbols?:number, files?:string[]}} report
347
+ * @returns {{check: object|null, pending: {at:number, files:string[]}|null}}
348
+ */
349
+ function reportRecords(report) {
350
+ if (!report.lines.length) return { check: null, pending: null };
351
+ const files = report.files || [];
352
+ return {
353
+ check: { hook: 'stop', action: 'stop_check', symbols: report.symbols || 0, callers: files.length },
354
+ pending: files.length ? { at: report.state.lastStopAt, files: files.slice(0, MAX_FOLLOWUP_FILES) } : null,
355
+ };
356
+ }
357
+
358
+ /**
359
+ * Whether a report was acted on: a caller file it listed was edited after it —
360
+ * an Edit logged since `pending.at`, or an mtime at or after it (Write, `sed
361
+ * -i`, formatters; the same reading as "touched this turn"). Judged at the
362
+ * next Stop, which is the end of the continuation the feedback starts.
363
+ * @param {{at:number, files:string[]}} pending left in the Stop state by the report
364
+ * @param {{ts:number, file:string}[]} edits the session's edit log
365
+ * @param {(file:string)=>number|null} mtimeMs
366
+ * @returns {{adopted:boolean, listed:number, edited:number}}
367
+ */
368
+ function followUpOf(pending, edits, mtimeMs) {
369
+ const listed = new Set(pending.files);
370
+ const edited = new Set();
371
+ for (const e of edits) {
372
+ if (e.ts >= pending.at && listed.has(e.file)) edited.add(e.file);
373
+ }
374
+ for (const f of listed) {
375
+ if (edited.has(f)) continue;
376
+ const m = mtimeMs(f);
377
+ if (m !== null && m >= pending.at) edited.add(f);
378
+ }
379
+ return { adopted: edited.size > 0, listed: listed.size, edited: edited.size };
380
+ }
381
+
382
+ function formatStopContext(lines) {
383
+ if (!lines.length) return null;
384
+ // No apostrophe in the fixed text: file names are single-quoted in it.
385
+ return '[code-graph] Signatures changed this turn (each compared with its file before ' +
386
+ 'the first edit of it this turn); their callers in files not edited this turn, from the code-graph index:\n' +
387
+ lines.join('\n') + '\n';
388
+ }
389
+
390
+ // --- Side-effecting shell ---------------------------------------------------
391
+
392
+ function readStdinJson() {
393
+ try {
394
+ // fd 0, not '/dev/stdin' (ENXIO on socketpair stdin) — as the other hooks.
395
+ return JSON.parse(fs.readFileSync(0, 'utf8'));
396
+ } catch { return null; }
397
+ }
398
+
399
+ function runMain() {
400
+ if (process.env.CODE_GRAPH_QUIET_HOOKS === '1') return;
401
+ const input = readStdinJson();
402
+ if (!input || !input.session_id) return;
403
+
404
+ const { resolveProjectRoot } = require('./project-root');
405
+ const root = resolveProjectRoot(typeof input.cwd === 'string' && input.cwd ? input.cwd : process.cwd());
406
+ if (root === null) return;
407
+
408
+ const sessionEdits = require('./session-edits');
409
+ const { recordRecommendation } = require('./recommendation-log');
410
+ const mtimeMs = (file) => {
411
+ try { return fs.statSync(path.join(root, file)).mtimeMs; } catch { return null; }
412
+ };
413
+ const state = sessionEdits.readStopState(root, input.session_id);
414
+ // The previous report's follow-up (D#163), at every Stop — the one ending
415
+ // the continuation that report started included. It only records: nothing
416
+ // is emitted, so a continuation cannot loop through it.
417
+ if (state.pending) {
418
+ const f = followUpOf(state.pending, sessionEdits.readEdits(root, input.session_id), mtimeMs);
419
+ recordRecommendation(root, { hook: 'stop', action: 'stop_followup', ...f });
420
+ delete state.pending;
421
+ sessionEdits.writeStopState(root, input.session_id, state);
422
+ }
423
+ // A continuation caused by a Stop hook is never re-checked.
424
+ if (input.stop_hook_active === true) return;
425
+
426
+ const edits = sessionEdits.readEdits(root, input.session_id);
427
+ if (edits.length === 0) return; // no log → nothing to check, and nothing written
428
+
429
+ const { remainingMs } = require('./hook-fail-open');
430
+ const run = (cmd, args, defaultMs) => {
431
+ const budget = remainingMs(defaultMs);
432
+ if (budget === null) return null;
433
+ try {
434
+ return execFileSync(cmd, args, hidden({
435
+ cwd: root,
436
+ timeout: budget,
437
+ encoding: 'utf8',
438
+ stdio: ['ignore', 'pipe', 'ignore'],
439
+ env: { ...process.env, CODE_GRAPH_INTERNAL: '1' },
440
+ maxBuffer: 16 * 1024 * 1024,
441
+ }));
442
+ } catch { return null; }
443
+ };
444
+
445
+ // The comparison no longer reads HEAD (baselines come from the edit log),
446
+ // but "silent outside a git work tree" is the spec's contract for this
447
+ // hook, so the gate stays.
448
+ const inGit = run('git', ['rev-parse', '--is-inside-work-tree'], 1000);
449
+ let binary = null;
450
+ let report = { lines: [], state: { lastStopAt: Date.now(), reported: state.reported } };
451
+ if (inGit !== null && inGit.trim() === 'true') {
452
+ binary = require('./find-binary').findBinary();
453
+ }
454
+ if (binary) {
455
+ report = computeStopReport({
456
+ edits,
457
+ state,
458
+ now: Date.now(),
459
+ workText: (file) => {
460
+ try { return fs.readFileSync(path.join(root, file), 'utf8'); } catch { return null; }
461
+ },
462
+ callers: (symbol, file) => {
463
+ const raw = run(binary, ['refs', symbol, '--file', file, '--relation', 'calls',
464
+ '--min-confidence', 'inferred', '--json'], 1500);
465
+ if (raw === null) return null;
466
+ let parsed;
467
+ try { parsed = JSON.parse(raw); } catch { return null; }
468
+ if (!parsed || !Array.isArray(parsed.references)) return null;
469
+ return parsed.references
470
+ .filter((r) => r && typeof r.file_path === 'string')
471
+ .map((r) => {
472
+ let text = null;
473
+ try { text = fs.readFileSync(path.join(root, r.file_path), 'utf8'); } catch { /* gone */ }
474
+ return { file: r.file_path, name: r.name, line: findCallSiteLine(text, symbol, r.start_line) };
475
+ });
476
+ },
477
+ mtimeMs,
478
+ });
479
+ }
480
+ const rec = reportRecords(report);
481
+ if (rec.check) recordRecommendation(root, rec.check);
482
+ if (rec.pending) report.state.pending = rec.pending;
483
+ sessionEdits.writeStopState(root, input.session_id, report.state);
484
+
485
+ const text = formatStopContext(report.lines);
486
+ if (!text) return;
487
+ const { emitEventContext } = require('./hook-emit');
488
+ process.stdout.write(emitEventContext('Stop', text) + '\n');
489
+ }
490
+
491
+ if (require.main === module) runMain();
492
+
493
+ module.exports = {
494
+ extractSignatures, signatureChanged, findCallSiteLine, computeStopReport, formatStopContext,
495
+ followUpOf, reportRecords, MAX_SYMBOLS_CHECKED, MAX_CALLERS_LISTED, MAX_FOLLOWUP_FILES,
496
+ };