@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.
- package/README.md +13 -0
- package/claude-plugin/.claude-plugin/plugin.json +1 -1
- package/claude-plugin/scripts/hook-emit.js +27 -1
- package/claude-plugin/scripts/hook-fail-open.js +5 -0
- package/claude-plugin/scripts/lifecycle.js +30 -2
- package/claude-plugin/scripts/post-grep-inject.js +28 -6
- package/claude-plugin/scripts/pre-edit-guide.js +21 -0
- package/claude-plugin/scripts/pre-grep-guide.js +435 -68
- package/claude-plugin/scripts/session-edits.js +141 -0
- package/claude-plugin/scripts/stop-impact.js +496 -0
- package/claude-plugin/scripts/subagent-start.js +103 -0
- package/claude-plugin/templates/code-graph-snapshot.yml +1 -1
- package/package.json +6 -6
|
@@ -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
|
+
};
|