@sdsrs/code-graph 0.129.0 → 0.130.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 CHANGED
@@ -520,6 +520,41 @@ defaults are what you get by doing nothing.
520
520
  | `CODE_GRAPH_PROJECT_TYPE=<type>` | Override project-type detection for the steering block. |
521
521
  | `CODE_GRAPH_FAIL_ON_RISK=1` | Make the PR impact comment fail the check on HIGH risk (CI). |
522
522
 
523
+ ### Sharing the statusline with another plugin
524
+
525
+ code-graph claims Claude Code's single `statusLine` slot and runs a **composite**
526
+ that calls each registered provider in turn, so other plugins keep their segment
527
+ instead of losing the slot. Whatever occupied it at install time is captured
528
+ automatically as the `_previous` provider and restored on uninstall.
529
+
530
+ A third-party plugin registers itself through the shipped CLI:
531
+
532
+ ```bash
533
+ # Resolve the script through the installed package. NOT $CLAUDE_PLUGIN_ROOT:
534
+ # Claude Code sets that per-plugin, so inside YOUR hook it points at YOUR
535
+ # plugin's root, and in a plain shell it is unset.
536
+ CG="$(npm root -g)/@sdsrs/code-graph/claude-plugin/scripts/statusline-chain.js"
537
+
538
+ # <id> is a stable name (your plugin's, not a version); <command> is a shell
539
+ # command that prints ONE line. Add --stdin if it expects Claude Code's status
540
+ # JSON on stdin.
541
+ node "$CG" register gsd "node /path/to/gsd-status.js" --stdin
542
+ node "$CG" list
543
+ node "$CG" unregister gsd
544
+ ```
545
+
546
+ | Detail | Value |
547
+ |---|---|
548
+ | Registry (working copy) | `~/.cache/code-graph/statusline-registry.json` |
549
+ | Durable mirror | `~/.claude/statusline-providers.json` — survives a cache wipe |
550
+ | Reserved ids | `code-graph`, `_previous` |
551
+ | Exit codes | `0` registered / unregistered / listed / already in that state · `1` usage · `2` reserved id, or a registry file that exists and cannot be read |
552
+
553
+ Exit 2 on an unreadable registry is deliberate: the mutation refuses rather than
554
+ rebuilding, because that file holds the user's previous statusline and other
555
+ plugins' entries. An installer that reads exit codes must not treat it as
556
+ "registered".
557
+
523
558
  <details>
524
559
  <summary><b>Internal and test-only</b> — set by the plugin's own processes, or by the test suite. Setting them by hand is not supported.</summary>
525
560
 
@@ -4,7 +4,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.129.0",
7
+ "version": "0.130.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -1057,6 +1057,10 @@ function isBinaryHealExhausted(state) {
1057
1057
  // release target, silent failure (an unhealable npm env must not block or spam).
1058
1058
 
1059
1059
  const SHELL_PKG = '@sdsrs/code-graph';
1060
+ // Exported because doctor.js renders "gave up" from the same threshold. It
1061
+ // used to hardcode a literal 3 next to a comment naming this constant, so
1062
+ // raising the cap here would have left the diagnosis reporting the old one
1063
+ // (audit 2026-08-29 JS-07).
1060
1064
  const GLOBAL_PKG_HEAL_MAX_ATTEMPTS = 3;
1061
1065
  const GLOBAL_PKG_HEAL_TIMEOUT_MS = 180000; // npm resolves + downloads the platform optionalDependency (~40MB)
1062
1066
 
@@ -1432,7 +1436,7 @@ module.exports = {
1432
1436
  isUpdateSuspended,
1433
1437
  getExtractedPluginVersion, readBinaryVersion, promoteVerifiedBinary,
1434
1438
  isSilentMode, isInstallMissingMode, isForceMode, isAutoUpdateDisabled,
1435
- MAX_UPDATE_ATTEMPTS,
1439
+ MAX_UPDATE_ATTEMPTS, GLOBAL_PKG_HEAL_MAX_ATTEMPTS,
1436
1440
  requestJson, resolveProxy, parseLatestRelease, fetchLatestRelease,
1437
1441
  PLUGIN_ASSET_NAME,
1438
1442
  downloadBinary, cachedBinaryPath, cachedBinaryNeedsUpdate, cachedBinaryStaleVsState,
@@ -89,6 +89,60 @@ function sanitizeSearchPath(searchPath) {
89
89
  return kept || undefined;
90
90
  }
91
91
 
92
+ /**
93
+ * The four runners below each spawned the binary and mapped the result by hand
94
+ * — the same fifteen lines, four times (audit 2026-08-29 ARC-07). Hoisted into
95
+ * three pieces, with the one genuine difference between them made a PARAMETER
96
+ * rather than a divergence you have to notice.
97
+ */
98
+
99
+ /** Explicit `opts.binary` → `_CG_ANSWER_BINARY` → `findBinary()`; null if none. */
100
+ function resolveAnswerBinary(opts) {
101
+ let binary = opts.binary;
102
+ if (binary === undefined) {
103
+ binary = process.env._CG_ANSWER_BINARY || require('./find-binary').findBinary();
104
+ }
105
+ return binary || null;
106
+ }
107
+
108
+ /** One spawn, one options block, one `CODE_GRAPH_INTERNAL` stamp. */
109
+ function runCg(binary, args, { cwd, timeoutMs }) {
110
+ return spawnSync(binary, args, hidden({
111
+ cwd,
112
+ timeout: timeoutMs,
113
+ encoding: 'utf8',
114
+ maxBuffer: 4 * 1024 * 1024,
115
+ stdio: ['ignore', 'pipe', 'ignore'],
116
+ // Hook-internal run: a delivered answer, not a model-initiated conversion.
117
+ // The CLI skips its recommendations.jsonl `use` record when this is set.
118
+ env: { ...process.env, CODE_GRAPH_INTERNAL: '1' },
119
+ }));
120
+ }
121
+
122
+ /**
123
+ * The exit-code table: 0 = answered, 1 = the query found nothing, anything else
124
+ * (plus a spawn error or a signal) = the tool did not run.
125
+ *
126
+ * `exitOneIsNoHits` is the whole reason this is a parameter and not a constant.
127
+ * `grep` and `callgraph` treat exit 1 as an empty result — the v0.50
128
+ * grep-parity contract. `overview` does NOT: its exit 1 means "no indexed files
129
+ * under that path", which the read-fanout hint reports as unavailable rather
130
+ * than as an answered-but-empty query. That difference predates this hoist and
131
+ * lived in four separate copies; folding it away silently would have changed
132
+ * one of them.
133
+ */
134
+ function classifyRun(res, { exitOneIsNoHits }) {
135
+ if (res.error || res.signal) return 'unavailable';
136
+ if (res.status === 1) return exitOneIsNoHits ? 'no-hits' : 'unavailable';
137
+ if (res.status !== 0) return 'unavailable';
138
+ return 'ok';
139
+ }
140
+
141
+ /** stdout carrying no answer: empty, or the CLI's own no-match line. */
142
+ function isEmptyAnswer(out) {
143
+ return !out || out.startsWith(NO_MATCH_PREFIX);
144
+ }
145
+
92
146
  /**
93
147
  * Run `code-graph-mcp grep <pattern> [searchPath]` synchronously.
94
148
  *
@@ -121,10 +175,7 @@ function runGrepAnswer(opts = {}) {
121
175
  if (!pattern || typeof pattern !== 'string' || pattern.length > MAX_PATTERN_LEN) {
122
176
  return { status: 'unavailable' };
123
177
  }
124
- let binary = opts.binary;
125
- if (binary === undefined) {
126
- binary = process.env._CG_ANSWER_BINARY || require('./find-binary').findBinary();
127
- }
178
+ const binary = resolveAnswerBinary(opts);
128
179
  if (!binary) return { status: 'no-binary' };
129
180
 
130
181
  // Defensive re-sanitize: callers should pass a clean path, but a glob
@@ -132,30 +183,13 @@ function runGrepAnswer(opts = {}) {
132
183
  const scope = sanitizeSearchPath(searchPath);
133
184
  const args = ['grep', pattern];
134
185
  if (scope) args.push(scope);
135
- const res = spawnSync(binary, args, hidden({
136
- cwd,
137
- timeout: timeoutMs,
138
- encoding: 'utf8',
139
- maxBuffer: 4 * 1024 * 1024,
140
- stdio: ['ignore', 'pipe', 'ignore'],
141
- // Hook-internal run: a delivered answer, not a model-initiated conversion.
142
- // The CLI skips its recommendations.jsonl `use` record when this is set.
143
- env: { ...process.env, CODE_GRAPH_INTERNAL: '1' },
144
- }));
145
- if (res.error || res.signal) {
146
- return { status: 'unavailable' };
147
- }
148
- // v0.50 grep-parity exit codes: 0 = matched, 1 = no match, 2 = error.
149
- // Older binaries exit 0 on no-match with the NO_MATCH_PREFIX on stderr
150
- // (stdout empty) — both shapes resolve to 'no-hits' below.
151
- if (res.status === 1) {
152
- return { status: 'no-hits' };
153
- }
154
- if (res.status !== 0) {
155
- return { status: 'unavailable' };
156
- }
186
+ const res = runCg(binary, args, { cwd, timeoutMs });
187
+ // Older binaries exit 0 on no-match with NO_MATCH_PREFIX on stdout — that
188
+ // shape resolves to 'no-hits' through isEmptyAnswer below.
189
+ const verdict = classifyRun(res, { exitOneIsNoHits: true });
190
+ if (verdict !== 'ok') return { status: verdict };
157
191
  const out = (res.stdout || '').trim();
158
- if (!out || out.startsWith(NO_MATCH_PREFIX)) {
192
+ if (isEmptyAnswer(out)) {
159
193
  return { status: 'no-hits' };
160
194
  }
161
195
  const { text, truncated } = truncateAtLine(out, maxBytes);
@@ -189,26 +223,19 @@ function runShowAnswer(opts = {}) {
189
223
  if (!Array.isArray(symbols) || symbols.length === 0) {
190
224
  return { status: 'unavailable' };
191
225
  }
192
- let binary = opts.binary;
193
- if (binary === undefined) {
194
- binary = process.env._CG_ANSWER_BINARY || require('./find-binary').findBinary();
195
- }
226
+ const binary = resolveAnswerBinary(opts);
196
227
  if (!binary) return { status: 'no-binary' };
197
228
 
198
229
  const parts = [];
199
230
  for (const sym of symbols.slice(0, 3)) {
200
231
  if (typeof sym !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(sym)) continue;
201
- const res = spawnSync(binary, ['show', sym], hidden({
202
- cwd,
203
- timeout: timeoutMs,
204
- encoding: 'utf8',
205
- maxBuffer: 4 * 1024 * 1024,
206
- stdio: ['ignore', 'pipe', 'ignore'],
207
- env: { ...process.env, CODE_GRAPH_INTERNAL: '1' },
208
- }));
209
- if (res.error || res.signal || res.status !== 0) continue;
232
+ const res = runCg(binary, ['show', sym], { cwd, timeoutMs });
233
+ // A symbol that did not resolve is SKIPPED, not fatal — exit 1 included,
234
+ // which is why this asks for `exitOneIsNoHits: false` and then treats
235
+ // every non-`ok` verdict the same way.
236
+ if (classifyRun(res, { exitOneIsNoHits: false }) !== 'ok') continue;
210
237
  const out = (res.stdout || '').trim();
211
- if (!out || out.startsWith(NO_MATCH_PREFIX)) continue;
238
+ if (isEmptyAnswer(out)) continue;
212
239
  parts.push(`$ code-graph-mcp show ${sym}\n${out}`);
213
240
  }
214
241
  if (parts.length === 0) return { status: 'no-hits' };
@@ -241,24 +268,25 @@ function runOverviewAnswer(opts = {}) {
241
268
  if (!dir || typeof dir !== 'string' || dir.length > 300) {
242
269
  return { status: 'unavailable' };
243
270
  }
244
- let binary = opts.binary;
245
- if (binary === undefined) {
246
- binary = process.env._CG_ANSWER_BINARY || require('./find-binary').findBinary();
247
- }
271
+ const binary = resolveAnswerBinary(opts);
248
272
  if (!binary) return { status: 'no-binary' };
249
- const res = spawnSync(binary, ['overview', dir], hidden({
250
- cwd,
251
- timeout: timeoutMs,
252
- encoding: 'utf8',
253
- maxBuffer: 4 * 1024 * 1024,
254
- stdio: ['ignore', 'pipe', 'ignore'],
255
- env: { ...process.env, CODE_GRAPH_INTERNAL: '1' },
256
- }));
257
- if (res.error || res.signal || res.status !== 0) {
258
- return { status: 'unavailable' };
273
+ const res = runCg(binary, ['overview', dir], { cwd, timeoutMs });
274
+ // Deliberately NOT exitOneIsNoHits: `overview` exits 1 for "no indexed
275
+ // files under that path", and this hint reports that as unavailable. It is
276
+ // the one arm that differs, preserved from the pre-hoist code.
277
+ //
278
+ // The verdict is RETURNED rather than collapsed to a literal
279
+ // `'unavailable'`. Collapsing it reads the same on this exit path and is
280
+ // not: it makes the flag above decorative, so flipping it changes nothing
281
+ // and no test can see the difference. Measured the first version of this
282
+ // hoist had exactly that shape, and the mutation that flips the flag stayed
283
+ // green against it.
284
+ const verdict = classifyRun(res, { exitOneIsNoHits: false });
285
+ if (verdict !== 'ok') {
286
+ return { status: verdict };
259
287
  }
260
288
  const out = (res.stdout || '').trim();
261
- if (!out || out.startsWith(NO_MATCH_PREFIX)) return { status: 'no-hits' };
289
+ if (isEmptyAnswer(out)) return { status: 'no-hits' };
262
290
  const { text, truncated } = truncateAtLine(out, maxBytes);
263
291
  return { status: 'hits', text, truncated };
264
292
  } catch {
@@ -295,27 +323,16 @@ function runCallgraphAnswer(opts = {}) {
295
323
  if (typeof symbol !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(symbol)) {
296
324
  return { status: 'unavailable' };
297
325
  }
298
- let binary = opts.binary;
299
- if (binary === undefined) {
300
- binary = process.env._CG_ANSWER_BINARY || require('./find-binary').findBinary();
301
- }
326
+ const binary = resolveAnswerBinary(opts);
302
327
  if (!binary) return { status: 'no-binary' };
303
328
 
304
- const res = spawnSync(binary, ['callgraph', symbol], hidden({
305
- cwd,
306
- timeout: timeoutMs,
307
- encoding: 'utf8',
308
- maxBuffer: 4 * 1024 * 1024,
309
- stdio: ['ignore', 'pipe', 'ignore'],
310
- env: { ...process.env, CODE_GRAPH_INTERNAL: '1' },
311
- }));
312
- if (res.error || res.signal) return { status: 'unavailable' };
329
+ const res = runCg(binary, ['callgraph', symbol], { cwd, timeoutMs });
313
330
  // grep-parity exit codes: 1 = symbol not found (no graph node).
314
- if (res.status === 1) return { status: 'no-hits' };
315
- if (res.status !== 0) return { status: 'unavailable' };
331
+ const verdict = classifyRun(res, { exitOneIsNoHits: true });
332
+ if (verdict !== 'ok') return { status: verdict };
316
333
  const out = (res.stdout || '').trim();
317
334
  // Only an edge-bearing tree is marginal over the grep the model already ran.
318
- if (!out || out.startsWith(NO_MATCH_PREFIX) ||
335
+ if (isEmptyAnswer(out) ||
319
336
  !(out.includes('← called by') || out.includes('→ calls'))) {
320
337
  return { status: 'no-hits' };
321
338
  }
@@ -12,7 +12,7 @@ const {
12
12
  } = require('./lifecycle');
13
13
  const { findBinary, clearCache: clearBinaryCache } = require('./find-binary');
14
14
  const { hidden } = require('./proc-opts');
15
- const { MAX_UPDATE_ATTEMPTS, isBinaryHealExhausted } = require('./auto-update');
15
+ const { MAX_UPDATE_ATTEMPTS, GLOBAL_PKG_HEAL_MAX_ATTEMPTS, isBinaryHealExhausted, readState } = require('./auto-update');
16
16
 
17
17
  // ── Diagnostics ───────────────────────────────────────────
18
18
 
@@ -356,9 +356,28 @@ function runDiagnostics({ checkOnly = false } = {}) {
356
356
  }
357
357
 
358
358
  // 5. Auto-update state
359
- try {
360
- const state = readJson(path.join(CACHE_DIR, 'update-state.json'));
359
+ //
360
+ // Read through auto-update's own `readState`, which distinguishes three
361
+ // outcomes: a missing file (`{}`), a readable one, and an unreadable one
362
+ // (`{ stateUnreadable: <code> }`). doctor used lifecycle's `readJson`, which
363
+ // collapses all three to `null` — so a corrupt state file, and a machine that
364
+ // had never checked at all, both rendered as "Auto-update ✅ up-to-date", a
365
+ // claim with nothing behind it (audit 2026-08-29 JS-11). The `catch` arm that
366
+ // was supposed to cover this could not fire: `readJson` never throws.
367
+ {
368
+ const state = readState();
361
369
  const attempts = (state && state.updateAttempts) || 0;
370
+ if (state && state.stateUnreadable) {
371
+ results.push({
372
+ name: 'Auto-update',
373
+ status: 'warn',
374
+ detail: `update-state.json is unreadable (${state.stateUnreadable}) — the next check `
375
+ + 'rewrites it, so this clears itself; until then the throttle and the '
376
+ + 'failed-install counter are both starting from zero.',
377
+ });
378
+ } else if (!state || Object.keys(state).length === 0) {
379
+ results.push({ name: 'Auto-update', status: 'ok', detail: 'no update state yet (no check has run)' });
380
+ } else
362
381
  if (state && state.updateAvailable && attempts >= MAX_UPDATE_ATTEMPTS) {
363
382
  // The updater has given up on this release (issue #40). Deliberately NO
364
383
  // fixId: re-running `auto-update.js check` is precisely the thing that was
@@ -381,8 +400,6 @@ function runDiagnostics({ checkOnly = false } = {}) {
381
400
  } else {
382
401
  results.push({ name: 'Auto-update', status: 'ok', detail: 'up-to-date' });
383
402
  }
384
- } catch {
385
- results.push({ name: 'Auto-update', status: 'ok', detail: 'no update state' });
386
403
  }
387
404
 
388
405
  // 6. Hook paths validity
@@ -548,10 +565,14 @@ function runDiagnostics({ checkOnly = false } = {}) {
548
565
  if (found.length) {
549
566
  const marker = !!readJson(GLOBAL_INSTALL_MARKER);
550
567
  // Heal-exhausted is otherwise invisible: selfHealGlobalPkgs stops after
551
- // 3 failed npm runs per target version and stays silent until the next
552
- // release re-arms the counter — a drifted CLI shim just sits there.
568
+ // GLOBAL_PKG_HEAL_MAX_ATTEMPTS failed npm runs per target version and
569
+ // stays silent until the next release re-arms the counter — a drifted CLI
570
+ // shim just sits there. The threshold is imported rather than repeated:
571
+ // this line used to hardcode 3 while the comment named the constant, so
572
+ // raising the cap in auto-update.js would have left this diagnosis
573
+ // reporting the old one (audit 2026-08-29 JS-07).
553
574
  const state = readJson(path.join(CACHE_DIR, 'update-state.json')) || {};
554
- const healGaveUp = (state.globalPkgHealAttempts || 0) >= 3;
575
+ const healGaveUp = (state.globalPkgHealAttempts || 0) >= GLOBAL_PKG_HEAL_MAX_ATTEMPTS;
555
576
  results.push({
556
577
  name: 'Global npm packages',
557
578
  status: healGaveUp ? 'warn' : 'ok',
@@ -879,14 +900,12 @@ function runRepairs(results, {
879
900
  console.log(' for semantic search rebuild with `cargo build --release --features embed-model`)');
880
901
  }
881
902
  console.log(` \u2192 ${buildCmd}`);
903
+ // Through the injectable helper, like the binary-broken arm below
904
+ // (audit 2026-08-29 ARC-02). An inline `execSync` here is not merely
905
+ // duplication: `execSync` is destructured at load, so a test injecting
906
+ // `buildBinary` leaves this arm running a REAL ten-minute cargo build.
882
907
  try {
883
- const projectRoot = path.resolve(__dirname, '..', '..');
884
- execSync(buildCmd, hidden({
885
- cwd: projectRoot,
886
- stdio: 'inherit',
887
- timeout: 600000, // embed-model (Candle) builds exceed the old 5min
888
- }));
889
- clearBinaryCache();
908
+ buildBinary(buildCmd);
890
909
  console.log(' \u2705 Build complete');
891
910
  fixed++;
892
911
  } catch {
@@ -903,13 +922,8 @@ function runRepairs(results, {
903
922
  console.log(' \u2192 cargo build --release --no-default-features');
904
923
  console.log(' (for semantic search: cargo build --release --features embed-model)');
905
924
  try {
906
- const projectRoot = path.resolve(__dirname, '..', '..');
907
- execSync('cargo build --release --no-default-features', hidden({
908
- cwd: projectRoot,
909
- stdio: 'inherit',
910
- timeout: 600000,
911
- }));
912
- clearBinaryCache();
925
+ // Same injectable helper as the other two build arms (ARC-02).
926
+ buildBinary('cargo build --release --no-default-features');
913
927
  console.log(' \u2705 Build complete');
914
928
  fixed++;
915
929
  } catch {
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // Fail-open wrapper for hook entry points (audit 2026-08-29 JS-12).
4
+ //
5
+ // A hook is optional housekeeping running inside somebody else's tool call. Its
6
+ // internal operations each carry their own guards, so this is defence in depth
7
+ // rather than a known crash — but the cost asymmetry is total: an unhandled
8
+ // throw prints a node stack trace into the user's session and exits non-zero,
9
+ // for work whose entire value is being unobtrusive. `session-init.js` learned
10
+ // this in audit 2026-08-16 P1-16 and grew a try/catch; the other eight entry
11
+ // points did not.
12
+ //
13
+ // A `process.on` handler rather than a try/catch around each `main()`, for two
14
+ // reasons. Two of the entry points (`pre-edit-guide.js`, `statusline.js`) are
15
+ // straight-line scripts with no main function to wrap, so a try/catch means
16
+ // re-indenting the whole file — a large diff through the exact hooks that gate
17
+ // Edit and the statusline. And a handler also covers the async escape, which is
18
+ // the one that actually happens: a rejected promise from a spawn or a read.
19
+ //
20
+ // EPIPE is silent by design. It means the consumer closed the pipe — Claude
21
+ // Code moved on, or a `| head` upstream exited. There is nobody left to tell.
22
+ function installHookFailOpen(label) {
23
+ const bail = (err) => {
24
+ const code = (err && err.code) || (err && err.name) || 'Error';
25
+ if (code !== 'EPIPE') {
26
+ try {
27
+ process.stderr.write(
28
+ `[code-graph] ${label} hook error (${code}): ${(err && err.message) || String(err)}\n` +
29
+ ' The tool call continues; run `code-graph-mcp doctor` if this repeats.\n'
30
+ );
31
+ } catch { /* stderr is gone too — there is nothing further to do */ }
32
+ }
33
+ // Exit 0, not the throw's non-zero: for a PreToolUse hook a non-zero exit is
34
+ // a DECISION (2 = deny), so crashing must not read as a verdict.
35
+ //
36
+ // Known limit: `process.exit` does not flush a pending stdout write, so an
37
+ // async throw AFTER a partial decision write could truncate it mid-JSON.
38
+ // Every entry point here writes its decision in one final call, so the
39
+ // window is "threw between that write and process exit". Draining instead
40
+ // (setting `process.exitCode` and returning) trades that for a hang risk
41
+ // against the hook's own 3-10s timeout, which is the worse failure — a
42
+ // timeout blocks the user's tool call, a truncated write does not.
43
+ process.exit(0);
44
+ };
45
+ process.on('uncaughtException', bail);
46
+ process.on('unhandledRejection', bail);
47
+ }
48
+
49
+ module.exports = { installHookFailOpen };
@@ -1,5 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
+ // FIRST statement, before this file's other requires (pre-tag review
4
+ // 2026-09-02): the handler installed after them could not catch a throw
5
+ // from `require('./lifecycle')` itself, which is exactly the broken-install
6
+ // case JS-12 exists for. Guarded on `require.main` so importing this module
7
+ // in a test does NOT install a process-wide handler that exits 0 — that
8
+ // would swallow the test's own failures.
9
+ if (require.main === module) require('./hook-fail-open').installHookFailOpen('PostToolUse:Write|Edit');
10
+
3
11
  const { execFileSync } = require('child_process');
4
12
  const { findBinary } = require('./find-binary');
5
13
  const { hidden } = require('./proc-opts');
@@ -139,6 +139,42 @@ function readJson(filePath) {
139
139
  // will usually fail for the same reason the read did, and that failure is the
140
140
  // point: it makes the caller refuse rather than overwrite. Returns the backup
141
141
  // path, or null when no copy could be made.
142
+ /** How many `.corrupt-*` copies of one file to keep. */
143
+ const MAX_CORRUPT_BACKUPS = 5;
144
+
145
+ /**
146
+ * Delete all but the newest `MAX_CORRUPT_BACKUPS` copies of `filePath`.
147
+ *
148
+ * The copies are a safety net, and a safety net nobody ever empties is a leak:
149
+ * each one is a full settings.json, they are created on a path that can repeat,
150
+ * and nothing else deletes them (audit 2026-08-29 JS-09). Newest survive because
151
+ * the reason to keep any is "undo what just happened".
152
+ *
153
+ * Deliberately narrow: same directory, exactly `<basename>.corrupt-` prefixed,
154
+ * regular files only. It runs inside `~/.claude`, so the matcher is a prefix on
155
+ * a name this function itself produced, never a glob.
156
+ */
157
+ function pruneCorruptBackups(filePath, keep = MAX_CORRUPT_BACKUPS) {
158
+ const dir = path.dirname(filePath);
159
+ const prefix = `${path.basename(filePath)}.corrupt-`;
160
+ let pruned = 0;
161
+ try {
162
+ const mine = fs.readdirSync(dir, { withFileTypes: true })
163
+ .filter((e) => e.isFile() && e.name.startsWith(prefix))
164
+ .map((e) => {
165
+ const full = path.join(dir, e.name);
166
+ // Sort by the timestamp we wrote into the NAME, not by mtime: a restore
167
+ // or a copy re-stamps mtime and would reorder the history.
168
+ return { full, name: e.name };
169
+ })
170
+ .sort((a, b) => b.name.localeCompare(a.name));
171
+ for (const stale of mine.slice(keep)) {
172
+ try { fs.unlinkSync(stale.full); pruned++; } catch { /* raced or read-only */ }
173
+ }
174
+ } catch { /* unreadable dir — the copy still succeeded, which is what matters */ }
175
+ return pruned;
176
+ }
177
+
142
178
  function backupCorruptFile(filePath, raw) {
143
179
  const stamp = new Date().toISOString().replace(/[:.]/g, '-');
144
180
  const dest = `${filePath}.corrupt-${stamp}`;
@@ -147,6 +183,7 @@ function backupCorruptFile(filePath, raw) {
147
183
  if (Buffer.isBuffer(raw)) fs.writeFileSync(dest, raw);
148
184
  else if (typeof raw === 'string') fs.writeFileSync(dest, Buffer.from(raw, 'utf8'));
149
185
  else fs.copyFileSync(filePath, dest);
186
+ pruneCorruptBackups(filePath);
150
187
  return dest;
151
188
  } catch {
152
189
  return null;
@@ -1201,7 +1238,20 @@ function verifyHooksFire({ hooks, env, timeoutMs = 4000, tmpBase } = {}) {
1201
1238
  function install({ reclaimStatusline = false } = {}) {
1202
1239
  const version = getPluginVersion();
1203
1240
  const manifest = readManifest();
1204
- const { settings, backedUpTo } = readSettingsForWrite();
1241
+ // Probe FIRST, and pay the backup only on the write path (audit 2026-08-29
1242
+ // JS-09). `readSettingsForWrite()` with no argument takes its `.corrupt-*`
1243
+ // copy eagerly, which is right for a caller that is certainly going to
1244
+ // rewrite the file and wrong here: install() is idempotent and usually
1245
+ // changes nothing. Combined with any condition that re-runs it every session
1246
+ // — the documented `manifestUnwritable` loop is one — a settings.json with a
1247
+ // single non-UTF-8 byte grew one timestamped copy in ~/.claude per session,
1248
+ // unbounded, for a file nothing ever rewrote. Same reasoning as
1249
+ // `cleanupDisabledStatusline`, which already probes.
1250
+ const probe = readJsonResult(settingsPath());
1251
+ const deferBackup = Boolean(probe.value && probe.lossy);
1252
+ let { settings, backedUpTo } = deferBackup
1253
+ ? { settings: probe.value, backedUpTo: null }
1254
+ : readSettingsForWrite(probe);
1205
1255
  if (!settings) {
1206
1256
  // Unusable settings.json that we could not even copy aside. Bail without
1207
1257
  // touching it — and without stamping the manifest, so the next run retries
@@ -1303,6 +1353,22 @@ function install({ reclaimStatusline = false } = {}) {
1303
1353
 
1304
1354
  // 3. Write settings atomically if changed
1305
1355
  if (settingsChanged) {
1356
+ if (deferBackup) {
1357
+ // Now it IS a rewrite, so the deferred copy is owed. A failure to make it
1358
+ // is the same refusal as the eager path: skipping the settings work beats
1359
+ // destroying bytes we cannot restore.
1360
+ const late = readSettingsForWrite(probe);
1361
+ if (!late.settings) {
1362
+ return {
1363
+ version,
1364
+ settingsChanged: false,
1365
+ statusLineClaimed: manifest.config.statusLine,
1366
+ hooksRegistered: false,
1367
+ settingsUnreadable: true,
1368
+ };
1369
+ }
1370
+ backedUpTo = late.backedUpTo;
1371
+ }
1306
1372
  const writeErr = tryWriteSettings(settings);
1307
1373
  if (writeErr) {
1308
1374
  // Do NOT fall through to the manifest stamp. A manifest carrying the
@@ -1838,7 +1904,7 @@ module.exports = {
1838
1904
  cleanupDisabledStatusline, unadoptRegisteredProjects,
1839
1905
  reportUnadoptSweep, // exported so its three-way bucketing is testable (audit 2026-08-29 JS-06)
1840
1906
  readManifest, readJson, readJsonResult, readSettingsForWrite, writeJsonAtomic,
1841
- backupCorruptFile, // auto-update.js repoints installed_plugins.json and owes the same preserve-then-proceed route
1907
+ backupCorruptFile, pruneCorruptBackups, MAX_CORRUPT_BACKUPS, // auto-update.js repoints installed_plugins.json and owes the same preserve-then-proceed route
1842
1908
  migrateOldPluginIds, // exported so its failure arms are testable (audit 2026-08-22 P2-10)
1843
1909
  readRegistry, readRegistryForWrite, writeRegistry,
1844
1910
  getPluginVersion, cleanupOldCacheVersions,
@@ -1930,6 +1996,16 @@ if (require.main === module) {
1930
1996
  // argument — `lifecycle.js doctor --check-onlyy` ran the full repair pass.
1931
1997
  // Exit code still reflects issues that remain UNRESOLVED after repair, not
1932
1998
  // issues found (see unresolvedCount in doctor.js).
1999
+ //
2000
+ // LAZY ON PURPOSE — do not hoist (audit 2026-08-29 JS-13). The plugin's JS
2001
+ // has exactly one require cycle: auto-update → lifecycle → doctor →
2002
+ // auto-update. Deferring this one edge to call time is the only thing that
2003
+ // keeps the top-level graph a DAG. Hoisted, `require('./auto-update')`
2004
+ // reaches `doctor` before auto-update has finished evaluating, so doctor
2005
+ // sees a half-built module and dies on load — from a module nobody in that
2006
+ // chain was even asking about. Pinned by
2007
+ // `every_hook_module_loads_first_in_a_cold_process` in lifecycle.test.js,
2008
+ // which loads each module first in its own process.
1933
2009
  const { runDoctorCli } = require('./doctor');
1934
2010
  process.exit(runDoctorCli(process.argv.slice(3)));
1935
2011
  } else if (cmd === 'verify-hooks-fire') {
@@ -1,5 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
+ // FIRST statement, before this file's other requires (pre-tag review
4
+ // 2026-09-02): the handler installed after them could not catch a throw
5
+ // from `require('./lifecycle')` itself, which is exactly the broken-install
6
+ // case JS-12 exists for. Guarded on `require.main` so importing this module
7
+ // in a test does NOT install a process-wide handler that exits 0 — that
8
+ // would swallow the test's own failures.
9
+ if (require.main === module) require('./hook-fail-open').installHookFailOpen('PostToolUse:Bash');
10
+
3
11
  // PostToolUse(Bash) hook: deliver cg's AST-aware answer for a FOLDABLE grep that
4
12
  // rode inside a COMPOUND command and therefore flew past the PreToolUse deny gate
5
13
  // (`echo "..." && grep Sym tests/`, `git diff && grep ...`, `for s in …; do grep`).
@@ -22,8 +30,7 @@
22
30
 
23
31
  const fs = require('fs');
24
32
  const path = require('path');
25
- const crypto = require('crypto');
26
- const { cgTmpDir, cwdHash } = require('./tmp-dir');
33
+ const { cgTmpDir, cwdHash, makeCooldown } = require('./tmp-dir');
27
34
  const { recordRecommendation } = require('./recommendation-log');
28
35
  const { runGrepAnswer, runShowAnswer, runCallgraphAnswer, sanitizeSearchPath } = require('./cg-answer');
29
36
  const { emitPostToolContext } = require('./hook-emit');
@@ -213,25 +220,10 @@ function isInjectDisabled(env = process.env) {
213
220
  // Per-command cooldown, mirror of pre-grep-guide's flag pattern but with a
214
221
  // DISTINCT prefix so the two hooks never share a flag (a PreToolUse deny and a
215
222
  // PostToolUse inject for different commands must not suppress each other).
216
- function commandHash(cmd) {
217
- return crypto.createHash('sha1').update(String(cmd)).digest('hex').slice(0, 12);
218
- }
219
-
220
- // Project-scoped for the same reason as pre-grep-guide's (see cwdHash in
221
- // tmp-dir.js): one shared tmp dir means an un-scoped flag is machine-global.
222
- function flagPath(cmd, cwd = process.cwd()) {
223
- return path.join(cgTmpDir(), `.code-graph-postinject-${cwdHash(cwd)}-${commandHash(cmd)}`);
224
- }
225
-
226
- function isOnCooldown(cmd, now = Date.now(), windowMs = 60000, cwd = process.cwd()) {
227
- try {
228
- return now - fs.statSync(flagPath(cmd, cwd)).mtimeMs < windowMs;
229
- } catch { return false; }
230
- }
231
-
232
- function markCooldown(cmd, cwd = process.cwd()) {
233
- try { fs.writeFileSync(flagPath(cmd, cwd), ''); } catch { /* ok */ }
234
- }
223
+ // Shared implementation (ARC-06). The DISTINCT prefix is the load-bearing part:
224
+ // a PreToolUse deny and a PostToolUse inject for different commands must not
225
+ // suppress each other.
226
+ const { commandHash, isOnCooldown, markCooldown } = makeCooldown('postinject');
235
227
 
236
228
  // --- Main execution ---
237
229
 
@@ -1,5 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
+ // FIRST statement, before this file's other requires (pre-tag review
4
+ // 2026-09-02): the handler installed after them could not catch a throw
5
+ // from `require('./lifecycle')` itself, which is exactly the broken-install
6
+ // case JS-12 exists for. Guarded on `require.main` so importing this module
7
+ // in a test does NOT install a process-wide handler that exits 0 — that
8
+ // would swallow the test's own failures.
9
+ if (require.main === module) require('./hook-fail-open').installHookFailOpen('PreToolUse:Edit');
10
+
3
11
  // PreToolUse(Edit) hook: auto-inject impact analysis when editing function definitions.
4
12
  // Only fires when:
5
13
  // 1. The old_string contains a function/method definition (signature being modified)
@@ -1,5 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
+ // FIRST statement, before this file's other requires (pre-tag review
4
+ // 2026-09-02): the handler installed after them could not catch a throw
5
+ // from `require('./lifecycle')` itself, which is exactly the broken-install
6
+ // case JS-12 exists for. Guarded on `require.main` so importing this module
7
+ // in a test does NOT install a process-wide handler that exits 0 — that
8
+ // would swallow the test's own failures.
9
+ if (require.main === module) require('./hook-fail-open').installHookFailOpen('PreToolUse:Bash');
10
+
3
11
  // PreToolUse(Bash) hook: detect raw `grep`/`rg`/`ag` on the indexed source tree
4
12
  // and either BLOCK with suggestion (v0.32+) or HINT (legacy path). Closes the
5
13
  // "Bash comfort zone" leak — pre-training bias has Claude reach for `grep -rn`
@@ -38,8 +46,7 @@
38
46
 
39
47
  const fs = require('fs');
40
48
  const path = require('path');
41
- const crypto = require('crypto');
42
- const { cgTmpDir, cwdHash } = require('./tmp-dir');
49
+ const { cgTmpDir, cwdHash, makeCooldown } = require('./tmp-dir');
43
50
  const { recordRecommendation } = require('./recommendation-log');
44
51
  const { runGrepAnswer, runShowAnswer, sanitizeSearchPath } = require('./cg-answer');
45
52
 
@@ -523,25 +530,10 @@ function splitTopLevelSegments(cmd) {
523
530
  return out.map(s => s.trim()).filter(Boolean);
524
531
  }
525
532
 
526
- function commandHash(cmd) {
527
- return crypto.createHash('sha1').update(cmd).digest('hex').slice(0, 12);
528
- }
529
-
530
- // Project-scoped: the same `grep -rn "foo" src/` in two repos is two different
531
- // questions and must not share one 60s cooldown (see cwdHash in tmp-dir.js).
532
- function flagPath(cmd, cwd = process.cwd()) {
533
- return path.join(cgTmpDir(), `.code-graph-bash-${cwdHash(cwd)}-${commandHash(cmd)}`);
534
- }
535
-
536
- function isOnCooldown(cmd, now = Date.now(), windowMs = 60000, cwd = process.cwd()) {
537
- try {
538
- return now - fs.statSync(flagPath(cmd, cwd)).mtimeMs < windowMs;
539
- } catch { return false; }
540
- }
541
-
542
- function markCooldown(cmd, cwd = process.cwd()) {
543
- try { fs.writeFileSync(flagPath(cmd, cwd), ''); } catch { /* ok */ }
544
- }
533
+ // One implementation of the cooldown quartet, in tmp-dir.js (ARC-02/ARC-06);
534
+ // the `bash` prefix is what keeps this hook's flags distinct from
535
+ // post-grep-inject's.
536
+ const { commandHash, isOnCooldown, markCooldown } = makeCooldown('bash');
545
537
 
546
538
  function buildHint() {
547
539
  // Terse, no banner spam. Single message budget ~600 bytes.
@@ -1,5 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
+ // FIRST statement, before this file's other requires (pre-tag review
4
+ // 2026-09-02): the handler installed after them could not catch a throw
5
+ // from `require('./lifecycle')` itself, which is exactly the broken-install
6
+ // case JS-12 exists for. Guarded on `require.main` so importing this module
7
+ // in a test does NOT install a process-wide handler that exits 0 — that
8
+ // would swallow the test's own failures.
9
+ if (require.main === module) require('./hook-fail-open').installHookFailOpen('PreToolUse:Read');
10
+
3
11
  // PreToolUse(Read) hook: detect read-fanout into the same source directory
4
12
  // and suggest module_overview / `code-graph-mcp overview` once. The 7d audit
5
13
  // (2026-05-12 → 2026-05-14, 141 sessions) found 16 sessions with 5+ Reads
@@ -61,6 +61,13 @@ function runUnregister(id) {
61
61
  }
62
62
 
63
63
  function runList() {
64
+ // Refuses like its siblings (pre-tag review, 2026-09-02). `readRegistry()`
65
+ // returns [] for an unreadable file as well as for an empty one, so `list`
66
+ // answered a corrupt registry with `(empty)` at exit 0 — telling an installer
67
+ // that nothing is registered while other plugins' entries and the user's
68
+ // previous statusline sit in a file we could not parse. Same defect class as
69
+ // JS-11 in this batch: a corrupt file rendered as a clean state.
70
+ bailIfRegistryUnusable('listed');
64
71
  const registry = readRegistry();
65
72
  if (registry.length === 0) {
66
73
  process.stdout.write('(empty)\n');
@@ -1,5 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
+ // FIRST statement, before this file's other requires (pre-tag review
4
+ // 2026-09-02): the handler installed after them could not catch a throw
5
+ // from `require('./lifecycle')` itself, which is exactly the broken-install
6
+ // case JS-12 exists for. Guarded on `require.main` so importing this module
7
+ // in a test does NOT install a process-wide handler that exits 0 — that
8
+ // would swallow the test's own failures.
9
+ if (require.main === module) require('./hook-fail-open').installHookFailOpen('statusLine');
10
+
3
11
  /**
4
12
  * Composite StatusLine — combines multiple statusline providers.
5
13
  * Reads stdin (JSON context from Claude Code), pipes to the primary
@@ -36,7 +44,9 @@ function main() {
36
44
  }
37
45
 
38
46
  // Only run the statusline when invoked as a CLI; `require()` (tests) just imports helpers.
39
- if (require.main === module) main();
47
+ if (require.main === module) {
48
+ main();
49
+ }
40
50
 
41
51
  function run(stdin) {
42
52
  const registry = readRegistry();
@@ -1,5 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
+ // FIRST statement, before this file's other requires (pre-tag review
4
+ // 2026-09-02): the handler installed after them could not catch a throw
5
+ // from `require('./lifecycle')` itself, which is exactly the broken-install
6
+ // case JS-12 exists for. Guarded on `require.main` so importing this module
7
+ // in a test does NOT install a process-wide handler that exits 0 — that
8
+ // would swallow the test's own failures.
9
+ if (require.main === module) require('./hook-fail-open').installHookFailOpen('statusLine');
10
+
3
11
  const { execFileSync } = require('child_process');
4
12
  const fs = require('fs');
5
13
  const os = require('os');
@@ -41,6 +41,39 @@ function cwdHash(cwd) {
41
41
  return crypto.createHash('sha1').update(String(cwd)).digest('hex').slice(0, 12);
42
42
  }
43
43
 
44
+ // The per-command cooldown quartet — commandHash / flagPath / isOnCooldown /
45
+ // markCooldown — was written twice, near-byte-identically, in pre-grep-guide.js
46
+ // and post-grep-inject.js (audit 2026-08-29 ARC-06). The only thing that
47
+ // differed was the filename prefix, and a prefix is a parameter; the four
48
+ // functions are not. They already disagreed in one place: one hashed `cmd`, the
49
+ // other `String(cmd)`, so a non-string command threw in one hook and hashed in
50
+ // the other. This takes the `String()` form for both.
51
+ //
52
+ // The prefix stays the caller's, and must stay DISTINCT per hook: a PreToolUse
53
+ // deny and a PostToolUse inject for different commands must not suppress each
54
+ // other. Flag paths are byte-identical to what each hook wrote before.
55
+ //
56
+ // pre-read-guide.js is deliberately NOT folded in: its `.code-graph-readfan-*`
57
+ // file is a JSON state document keyed on the cwd alone, not a per-command
58
+ // cooldown flag — same directory, different thing.
59
+ function makeCooldown(prefix) {
60
+ const commandHash = (cmd) =>
61
+ crypto.createHash('sha1').update(String(cmd)).digest('hex').slice(0, 12);
62
+ // Project-scoped: the same `grep -rn "foo" src/` in two repos is two different
63
+ // questions and must not share one cooldown (see cwdHash above).
64
+ const flagPath = (cmd, cwd = process.cwd()) =>
65
+ path.join(cgTmpDir(), `.code-graph-${prefix}-${cwdHash(cwd)}-${commandHash(cmd)}`);
66
+ const isOnCooldown = (cmd, now = Date.now(), windowMs = 60000, cwd = process.cwd()) => {
67
+ try {
68
+ return now - fs.statSync(flagPath(cmd, cwd)).mtimeMs < windowMs;
69
+ } catch { return false; }
70
+ };
71
+ const markCooldown = (cmd, cwd = process.cwd()) => {
72
+ try { fs.writeFileSync(flagPath(cmd, cwd), ''); } catch { /* ok */ }
73
+ };
74
+ return { commandHash, flagPath, isOnCooldown, markCooldown };
75
+ }
76
+
44
77
  // Nothing ever deleted what cgTmpDir() collects. Every cooldown flag, read-fanout
45
78
  // state file and interrupted `update-*` download stayed forever: measured 281
46
79
  // entries on a working dev box, 232 of them older than a day. They are 0-byte
@@ -86,4 +119,4 @@ function pruneCgTmp({ now = Date.now(), maxAgeMs = PRUNE_MAX_AGE_MS, dir = CG_TM
86
119
  return removed;
87
120
  }
88
121
 
89
- module.exports = { cgTmpDir, CG_TMP_DIR, cwdHash, pruneCgTmp, PRUNE_MAX_AGE_MS };
122
+ module.exports = { cgTmpDir, CG_TMP_DIR, cwdHash, makeCooldown, pruneCgTmp, PRUNE_MAX_AGE_MS };
@@ -1,5 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
+ // FIRST statement, before this file's other requires (pre-tag review
4
+ // 2026-09-02): the handler installed after them could not catch a throw
5
+ // from `require('./lifecycle')` itself, which is exactly the broken-install
6
+ // case JS-12 exists for. Guarded on `require.main` so importing this module
7
+ // in a test does NOT install a process-wide handler that exits 0 — that
8
+ // would swallow the test's own failures.
9
+ if (require.main === module) require('./hook-fail-open').installHookFailOpen('UserPromptSubmit');
10
+
3
11
  // UserPromptSubmit hook: inject relevant code-graph RESULTS based on user's intent.
4
12
  // Strategy: PUSH structural context (not suggestions) that Grep/Read cannot provide.
5
13
  // This is a CODE INDEX — only inject structural code context (impact, overview, callgraph).
@@ -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.129.0 code-graph-mcp snapshot create --out snapshot.db
38
+ npx -y -p @sdsrs/code-graph@0.130.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.129.0",
3
+ "version": "0.130.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.129.0",
39
- "@sdsrs/code-graph-linux-arm64": "0.129.0",
40
- "@sdsrs/code-graph-darwin-x64": "0.129.0",
41
- "@sdsrs/code-graph-darwin-arm64": "0.129.0",
42
- "@sdsrs/code-graph-win32-x64": "0.129.0"
38
+ "@sdsrs/code-graph-linux-x64": "0.130.0",
39
+ "@sdsrs/code-graph-linux-arm64": "0.130.0",
40
+ "@sdsrs/code-graph-darwin-x64": "0.130.0",
41
+ "@sdsrs/code-graph-darwin-arm64": "0.130.0",
42
+ "@sdsrs/code-graph-win32-x64": "0.130.0"
43
43
  }
44
44
  }