@sdsrs/code-graph 0.160.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.
- package/README.md +13 -0
- package/claude-plugin/.claude-plugin/plugin.json +1 -1
- package/claude-plugin/scripts/cg-answer.js +51 -2
- 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 +40 -1
- package/claude-plugin/scripts/pre-edit-guide.js +21 -0
- package/claude-plugin/scripts/pre-grep-guide.js +536 -51
- package/claude-plugin/scripts/session-edits.js +134 -0
- package/claude-plugin/scripts/stop-impact.js +431 -0
- package/claude-plugin/scripts/subagent-start.js +95 -0
- package/claude-plugin/templates/code-graph-snapshot.yml +1 -1
- package/package.json +6 -6
|
@@ -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.
|
|
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
|