@sdsrs/code-graph 0.134.0 → 0.135.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.
@@ -4,7 +4,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.134.0",
7
+ "version": "0.135.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -105,11 +105,41 @@ function resolveAnswerBinary(opts) {
105
105
  return binary || null;
106
106
  }
107
107
 
108
- /** One spawn, one options block, one `CODE_GRAPH_INTERNAL` stamp. */
108
+ /**
109
+ * One spawn, one options block, one `CODE_GRAPH_INTERNAL` stamp.
110
+ *
111
+ * The timeout is the SMALLER of this call's own budget and whatever is left of
112
+ * the hook process's registered budget, because the callers run these in
113
+ * series: post-grep-inject loops callgraph over every symbol in the pattern
114
+ * before falling back to show and then grep, so three 2 s answers overran a 5 s
115
+ * hook and Claude Code killed it — which the user sees as a hook error on their
116
+ * own tool call, not as a missing hint (audit 2026-09-05 JS-03). Out of budget
117
+ * returns a synthetic timeout the exit-code table already reads as
118
+ * `unavailable`, so the caller degrades to the static path exactly as it does
119
+ * for a real timeout.
120
+ *
121
+ * `killSignal: 'SIGKILL'`: the child we are giving up on is most often one
122
+ * wedged waiting on `index.lock`, and node's `timeout` sends SIGTERM and then
123
+ * WAITS. It reads no stdin and holds no lock file worth unwinding — the same
124
+ * reasoning statusline.js and doctor.js already apply (see proc-opts.js).
125
+ */
109
126
  function runCg(binary, args, { cwd, timeoutMs }) {
127
+ const budget = require('./hook-fail-open').remainingMs(timeoutMs);
128
+ if (budget === null) {
129
+ // `error` is what classifyRun reads (→ `unavailable`); `budgetExhausted` is
130
+ // for the one caller that loops and must not mistake this for "that symbol
131
+ // did not resolve" — see runShowAnswer.
132
+ return {
133
+ error: new Error('hook budget exhausted'),
134
+ budgetExhausted: true,
135
+ status: null,
136
+ stdout: '',
137
+ };
138
+ }
110
139
  return spawnSync(binary, args, hidden({
111
140
  cwd,
112
- timeout: timeoutMs,
141
+ timeout: budget,
142
+ killSignal: 'SIGKILL',
113
143
  encoding: 'utf8',
114
144
  maxBuffer: 4 * 1024 * 1024,
115
145
  stdio: ['ignore', 'pipe', 'ignore'],
@@ -230,6 +260,15 @@ function runShowAnswer(opts = {}) {
230
260
  for (const sym of symbols.slice(0, 3)) {
231
261
  if (typeof sym !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(sym)) continue;
232
262
  const res = runCg(binary, ['show', sym], { cwd, timeoutMs });
263
+ // Out of hook budget is not "this symbol did not resolve". This loop
264
+ // `continue`s past every failure and reports `no-hits` when none of the
265
+ // three produced output, so without this arm an exhausted budget reached
266
+ // recordRecommendation as a genuine empty result — and the whole reason
267
+ // `no-binary` is kept distinct from `unavailable` (see this function's
268
+ // docs) is that the deny funnel has to tell those causes apart. Nothing
269
+ // later in the loop can succeed either: the budget is gone for all three
270
+ // (pre-ship review 2026-09-05).
271
+ if (res.budgetExhausted) return { status: 'unavailable' };
233
272
  // A symbol that did not resolve is SKIPPED, not fatal — exit 1 included,
234
273
  // which is why this asks for `exitOneIsNoHits: false` and then treats
235
274
  // every non-`ok` verdict the same way.
@@ -19,7 +19,87 @@
19
19
  //
20
20
  // EPIPE is silent by design. It means the consumer closed the pipe — Claude
21
21
  // Code moved on, or a `| head` upstream exited. There is nobody left to tell.
22
+ // The budget Claude Code kills each hook at, in SECONDS — the single source for
23
+ // both halves of that contract: `lifecycle.js` registers these numbers into
24
+ // settings.json, and `remainingMs` below spends against them. `hooks.test.js`
25
+ // pins both registration sites to this table so a bump in one cannot drift from
26
+ // the other.
27
+ //
28
+ // `session-init.js` is here for its REGISTRATION only. It is registered from
29
+ // `claude-plugin/hooks/hooks.json` (SessionStart is the one event Claude Code
30
+ // loads from plugin-cache), it predates `installHookFailOpen` and wraps its own
31
+ // main in a try/catch, so nothing arms a deadline for it and none of its six
32
+ // child spawns clamp — 15.5 s of serial timeouts against the 5 s below. That is
33
+ // the largest overrun of the seven and it is NOT fixed here; pre-ship review
34
+ // 2026-09-05 finding 1, carried in the audit report as NEW-05. Wiring it means
35
+ // deciding, per child, whether an out-of-budget SessionStart should skip a
36
+ // binary health check or a quarantine probe, which is not a mechanical change.
37
+ const HOOK_TIMEOUT_SECONDS = {
38
+ 'pre-edit-guide.js': 4,
39
+ 'pre-grep-guide.js': 3,
40
+ 'pre-read-guide.js': 3,
41
+ 'incremental-index.js': 10,
42
+ 'post-grep-inject.js': 5,
43
+ 'user-prompt-context.js': 5,
44
+ 'session-init.js': 5,
45
+ };
46
+
47
+ // Left for the hook to render its answer and exit after the last child returns.
48
+ const WRITE_RESERVE_MS = 400;
49
+
50
+ // Wall-clock instant this process must be finished by, or null when nothing
51
+ // armed one (a hook `require`d by a test, or an unlisted script).
52
+ let deadlineAt = null;
53
+
54
+ /**
55
+ * Arm the process deadline from the registered budget of `script`.
56
+ *
57
+ * The hooks' internal timeouts were each sized in isolation and run in SERIES:
58
+ * pre-edit-guide alone could spend 5 × 2000 ms of candidate greps plus 2500 ms
59
+ * of impact against a 4 s budget, and post-grep-inject looped callgraph over
60
+ * every symbol in the pattern before its show/grep fallbacks — 2–3× the budget
61
+ * either way. Nothing enforced the sum, so a binary wedged on `index.lock` got
62
+ * the hook killed by Claude Code, which surfaces to the user as a hook error on
63
+ * THEIR tool call (audit 2026-09-05 JS-03).
64
+ *
65
+ * `process.uptime()` is subtracted because the budget starts when Claude Code
66
+ * spawns us, not when this line runs: cold node startup is real time already
67
+ * spent, and on a loaded machine it is hundreds of milliseconds.
68
+ */
69
+ function armHookDeadline(script) {
70
+ const seconds = HOOK_TIMEOUT_SECONDS[script];
71
+ if (!seconds) return;
72
+ deadlineAt = Math.floor(Date.now() + seconds * 1000 - process.uptime() * 1000 - WRITE_RESERVE_MS);
73
+ }
74
+
75
+ /**
76
+ * How long a child may run: `defaultMs`, or whatever is left of the budget.
77
+ *
78
+ * Returns `null` when there is nothing left — meaning DO NOT RUN, not "run
79
+ * unbounded". Callers must branch on it: node reads `timeout: 0` as no timeout
80
+ * at all, so a numeric zero here would turn the last child into the unbounded
81
+ * one, which is the exact failure this exists to prevent.
82
+ *
83
+ * Always an INTEGER. `child_process` validates `timeout` with
84
+ * `validateTimeout` and throws `ERR_OUT_OF_RANGE` on a fraction — and the one
85
+ * fractional term here (`process.uptime()`) made every spawn throw, which the
86
+ * runners' own try/catch turned into a silent `unavailable`: the hooks stopped
87
+ * answering and still exited 0. Caught by pre-grep-guide's e2e suite.
88
+ */
89
+ function remainingMs(defaultMs) {
90
+ if (deadlineAt === null) return Math.floor(defaultMs);
91
+ const left = deadlineAt - Date.now();
92
+ if (left <= 0) return null;
93
+ return Math.floor(Math.min(defaultMs, left));
94
+ }
95
+
96
+ /** Test seam: drop any armed deadline so one test file can't leak into another. */
97
+ function resetHookDeadline() {
98
+ deadlineAt = null;
99
+ }
100
+
22
101
  function installHookFailOpen(label) {
102
+ armHookDeadline(require('path').basename(process.argv[1] || ''));
23
103
  const bail = (err) => {
24
104
  const code = (err && err.code) || (err && err.name) || 'Error';
25
105
  if (code !== 'EPIPE') {
@@ -46,4 +126,10 @@ function installHookFailOpen(label) {
46
126
  process.on('unhandledRejection', bail);
47
127
  }
48
128
 
49
- module.exports = { installHookFailOpen };
129
+ module.exports = {
130
+ installHookFailOpen,
131
+ HOOK_TIMEOUT_SECONDS,
132
+ armHookDeadline,
133
+ remainingMs,
134
+ resetHookDeadline,
135
+ };
@@ -142,6 +142,41 @@ function readJson(filePath) {
142
142
  /** How many `.corrupt-*` copies of one file to keep. */
143
143
  const MAX_CORRUPT_BACKUPS = 5;
144
144
 
145
+ /**
146
+ * Owner-only. What every file this module creates under `~/.claude` gets when
147
+ * there is no prior file whose bits to preserve, and what a `.corrupt-*` copy
148
+ * gets unconditionally: settings.json routinely carries an `env` block with API
149
+ * keys, and a backup of one is the same secret in a second file (audit
150
+ * 2026-09-05 JS-01).
151
+ */
152
+ const SECRET_FILE_MODE = 0o600;
153
+
154
+ /**
155
+ * Pin `target` at exactly `mode`, because neither route that creates our files
156
+ * does it on its own: `writeFileSync`'s `mode` option goes to `open(O_CREAT)`
157
+ * and is masked by umask (and ignored outright when the file already exists),
158
+ * and `copyFileSync` carries the SOURCE's bits.
159
+ *
160
+ * Reported, not swallowed: the whole point of the call is that the file may
161
+ * hold a key, so a permission we could not set is exactly the thing the user
162
+ * needs to hear about. The write itself has already succeeded — a failure here
163
+ * is a disclosure, not a reason to unwind it.
164
+ */
165
+ function restrictMode(target, mode) {
166
+ try {
167
+ fs.chmodSync(target, mode);
168
+ } catch (err) {
169
+ // No return value: both call sites act on the stderr line, not on a bool,
170
+ // and a discarded success flag is an invitation to branch on it later
171
+ // without noticing that nothing ever did (pre-ship review 2026-09-05).
172
+ console.error(
173
+ `[code-graph] wrote ${target} but could not set its permissions to ` +
174
+ `0${mode.toString(8)} (${err.code || err.name}). If it contains an API key, ` +
175
+ `it may be readable by other users on this machine.`
176
+ );
177
+ }
178
+ }
179
+
145
180
  /**
146
181
  * Delete all but the newest `MAX_CORRUPT_BACKUPS` copies of `filePath`.
147
182
  *
@@ -180,9 +215,14 @@ function backupCorruptFile(filePath, raw) {
180
215
  const dest = `${filePath}.corrupt-${stamp}`;
181
216
  try {
182
217
  // Buffer, not string: see readJsonResult. A string here would re-encode.
183
- if (Buffer.isBuffer(raw)) fs.writeFileSync(dest, raw);
184
- else if (typeof raw === 'string') fs.writeFileSync(dest, Buffer.from(raw, 'utf8'));
185
- else fs.copyFileSync(filePath, dest);
218
+ if (Buffer.isBuffer(raw)) fs.writeFileSync(dest, raw, { mode: SECRET_FILE_MODE });
219
+ else if (typeof raw === 'string') {
220
+ fs.writeFileSync(dest, Buffer.from(raw, 'utf8'), { mode: SECRET_FILE_MODE });
221
+ } else fs.copyFileSync(filePath, dest);
222
+ // Owner-only regardless of what the original was: up to MAX_CORRUPT_BACKUPS
223
+ // of these accumulate beside settings.json with its `env` block copied
224
+ // verbatim, so a 0644 original must not mint 0644 duplicates.
225
+ restrictMode(dest, SECRET_FILE_MODE);
186
226
  pruneCorruptBackups(filePath);
187
227
  return dest;
188
228
  } catch {
@@ -261,11 +301,23 @@ function readSettingsForWrite(pre) {
261
301
  return { settings: {}, backedUpTo: backup };
262
302
  }
263
303
 
304
+ // Replace-by-rename loses the permission bits unless they are carried over
305
+ // deliberately: the new inode is the TMP file's, created under our umask, so a
306
+ // settings.json the user had put at 0600 came back 0644 on the first
307
+ // install()/update()/cleanupDisabledStatusline() — with its `env` API keys in
308
+ // it, silently, on a file we were only meant to add two hook entries to (audit
309
+ // 2026-09-05 JS-01). Stat the original and restore its exact mode; when there
310
+ // is no original, create owner-only rather than at whatever umask says.
264
311
  function writeJsonAtomic(filePath, data) {
265
312
  const dir = path.dirname(filePath);
266
313
  fs.mkdirSync(dir, { recursive: true });
314
+ let mode = null;
315
+ try { mode = fs.statSync(filePath).mode & 0o777; } catch { /* new file */ }
267
316
  const tmp = filePath + '.tmp.' + process.pid;
268
- fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n');
317
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: mode ?? SECRET_FILE_MODE });
318
+ // umask can only have cleared bits the original had, so chmod is what makes
319
+ // this preservation rather than tightening.
320
+ restrictMode(tmp, mode ?? SECRET_FILE_MODE);
269
321
  fs.renameSync(tmp, filePath);
270
322
  }
271
323
 
@@ -907,7 +959,13 @@ function removeHooksFromSettings(settings) {
907
959
 
908
960
  function buildSettingsHookEntries() {
909
961
  const root = PLUGIN_ROOT;
910
- const scriptCmd = (name, timeout) => {
962
+ // The budget is read from the table the hooks THEMSELVES spend against
963
+ // (hook-fail-open.js), not written twice: a number registered here that the
964
+ // hook does not know about is the JS-03 shape — the hook overruns a limit it
965
+ // cannot see and Claude Code kills it mid-tool-call.
966
+ const { HOOK_TIMEOUT_SECONDS } = require('./hook-fail-open');
967
+ const scriptCmd = (name) => {
968
+ const timeout = HOOK_TIMEOUT_SECONDS[name];
911
969
  const script = path.join(root, 'scripts', name);
912
970
  // POSIX: existence-guarded. After `/plugin uninstall`, CC may delete the
913
971
  // plugin-cache dir before our statusline teardown gets to strip these
@@ -924,16 +982,16 @@ function buildSettingsHookEntries() {
924
982
 
925
983
  return {
926
984
  PreToolUse: [
927
- { description: SETTINGS_HOOK_DESC.preToolUse, matcher: 'Edit', hooks: [scriptCmd('pre-edit-guide.js', 4)] },
928
- { description: SETTINGS_HOOK_DESC.preToolUse, matcher: 'Bash', hooks: [scriptCmd('pre-grep-guide.js', 3)] },
929
- { description: SETTINGS_HOOK_DESC.preToolUse, matcher: 'Read', hooks: [scriptCmd('pre-read-guide.js', 3)] },
985
+ { description: SETTINGS_HOOK_DESC.preToolUse, matcher: 'Edit', hooks: [scriptCmd('pre-edit-guide.js')] },
986
+ { description: SETTINGS_HOOK_DESC.preToolUse, matcher: 'Bash', hooks: [scriptCmd('pre-grep-guide.js')] },
987
+ { description: SETTINGS_HOOK_DESC.preToolUse, matcher: 'Read', hooks: [scriptCmd('pre-read-guide.js')] },
930
988
  ],
931
989
  PostToolUse: [
932
- { description: SETTINGS_HOOK_DESC.postToolUseEdit, matcher: 'Write|Edit', hooks: [scriptCmd('incremental-index.js', 10)] },
933
- { description: SETTINGS_HOOK_DESC.postToolUseInject, matcher: 'Bash', hooks: [scriptCmd('post-grep-inject.js', 5)] },
990
+ { description: SETTINGS_HOOK_DESC.postToolUseEdit, matcher: 'Write|Edit', hooks: [scriptCmd('incremental-index.js')] },
991
+ { description: SETTINGS_HOOK_DESC.postToolUseInject, matcher: 'Bash', hooks: [scriptCmd('post-grep-inject.js')] },
934
992
  ],
935
993
  UserPromptSubmit: [
936
- { description: SETTINGS_HOOK_DESC.userPromptSubmit, matcher: '', hooks: [scriptCmd('user-prompt-context.js', 5)] },
994
+ { description: SETTINGS_HOOK_DESC.userPromptSubmit, matcher: '', hooks: [scriptCmd('user-prompt-context.js')] },
937
995
  ],
938
996
  };
939
997
  }
@@ -24,6 +24,7 @@ const { recordRecommendation } = require('./recommendation-log');
24
24
  const { formatCoveringTests } = require('./covering-tests');
25
25
  const { emitPreToolContext } = require('./hook-emit');
26
26
  const { hidden } = require('./proc-opts');
27
+ const { remainingMs } = require('./hook-fail-open');
27
28
 
28
29
  // v0.49 — walk up from the shell cwd (subdir-cwd fix). The per-cwd index.db
29
30
  // gate kept this hook dark for entire sessions after `cd backend/` — daagu
@@ -114,10 +115,17 @@ if (!symbol || symbol.length < 3) {
114
115
  const candidates = [...new Set(identifiers)]
115
116
  .filter(id => !skipWords.has(id.toLowerCase()))
116
117
  .sort((a, b) => b.length - a.length);
117
- for (const candidate of candidates.slice(0, 5)) {
118
+ // Two candidates, not five. They are sorted most-specific-first, so the
119
+ // 3rd–5th were the least likely to resolve AND the ones that pushed this
120
+ // loop past the hook's whole 4 s budget (5 × 2000 ms here + 2500 ms of
121
+ // impact below); the impact query they starve is the hook's actual output
122
+ // (audit 2026-09-05 JS-03).
123
+ for (const candidate of candidates.slice(0, 2)) {
124
+ const budget = remainingMs(2000);
125
+ if (budget === null) break;
118
126
  try {
119
127
  const raw = execFileSync(binary, ['grep', candidate, filePath, '--json'], hidden({
120
- cwd, timeout: 2000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
128
+ cwd, timeout: budget, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
121
129
  env: internalEnv,
122
130
  }));
123
131
  const grepResult = JSON.parse(raw);
@@ -170,6 +178,11 @@ try {
170
178
  const editedFile = (input.tool_input && input.tool_input.file_path) || '';
171
179
  const relFile = editedFile ? path.relative(cwd, editedFile) : '';
172
180
  let jsonResult;
181
+ // Whatever is left of the registered 4 s, capped at this call's own 2500 ms.
182
+ // `null` = the candidate loop above already spent the budget; running anyway is
183
+ // what got the hook killed by Claude Code mid-Edit (audit 2026-09-05 JS-03).
184
+ const impactBudget = remainingMs(2500);
185
+ if (impactBudget === null) process.exit(0);
173
186
  try {
174
187
  const args = ['impact', symbol, '--json'];
175
188
  if (relFile && !relFile.startsWith('..')) args.push('--file', relFile);
@@ -177,7 +190,7 @@ try {
177
190
  // diverging from the findBinary() result the rest of the hook trusts).
178
191
  const raw = execFileSync(binary, args, hidden({
179
192
  cwd,
180
- timeout: 2500,
193
+ timeout: impactBudget,
181
194
  encoding: 'utf8',
182
195
  stdio: ['pipe', 'pipe', 'pipe'],
183
196
  env: internalEnv,
@@ -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.134.0 code-graph-mcp snapshot create --out snapshot.db
38
+ npx -y -p @sdsrs/code-graph@0.135.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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/code-graph",
3
- "version": "0.134.0",
3
+ "version": "0.135.0",
4
4
  "description": "MCP server that indexes codebases into an AST knowledge graph with semantic search, call graph traversal, and HTTP route tracing",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -35,10 +35,10 @@
35
35
  "node": ">=16"
36
36
  },
37
37
  "optionalDependencies": {
38
- "@sdsrs/code-graph-linux-x64": "0.134.0",
39
- "@sdsrs/code-graph-linux-arm64": "0.134.0",
40
- "@sdsrs/code-graph-darwin-x64": "0.134.0",
41
- "@sdsrs/code-graph-darwin-arm64": "0.134.0",
42
- "@sdsrs/code-graph-win32-x64": "0.134.0"
38
+ "@sdsrs/code-graph-linux-x64": "0.135.0",
39
+ "@sdsrs/code-graph-linux-arm64": "0.135.0",
40
+ "@sdsrs/code-graph-darwin-x64": "0.135.0",
41
+ "@sdsrs/code-graph-darwin-arm64": "0.135.0",
42
+ "@sdsrs/code-graph-win32-x64": "0.135.0"
43
43
  }
44
44
  }