@sdsrs/code-graph 0.129.0 → 0.131.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 +35 -0
- package/claude-plugin/.claude-plugin/plugin.json +1 -1
- package/claude-plugin/scripts/auto-update.js +5 -1
- package/claude-plugin/scripts/cg-answer.js +89 -72
- package/claude-plugin/scripts/doctor.js +36 -22
- package/claude-plugin/scripts/hook-fail-open.js +49 -0
- package/claude-plugin/scripts/incremental-index.js +8 -0
- package/claude-plugin/scripts/lifecycle.js +78 -2
- package/claude-plugin/scripts/post-grep-inject.js +13 -21
- package/claude-plugin/scripts/pre-edit-guide.js +8 -0
- package/claude-plugin/scripts/pre-grep-guide.js +13 -21
- package/claude-plugin/scripts/pre-read-guide.js +8 -0
- package/claude-plugin/scripts/recommendation-log.js +131 -4
- package/claude-plugin/scripts/statusline-chain.js +7 -0
- package/claude-plugin/scripts/statusline-composite.js +11 -1
- package/claude-plugin/scripts/statusline.js +8 -0
- package/claude-plugin/scripts/tmp-dir.js +34 -1
- package/claude-plugin/scripts/user-prompt-context.js +8 -0
- package/claude-plugin/templates/code-graph-snapshot.yml +1 -1
- package/package.json +6 -6
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
|
|
|
@@ -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
|
-
|
|
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 =
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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 (
|
|
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
|
-
|
|
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 =
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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 (
|
|
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
|
-
|
|
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 =
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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 (
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
315
|
-
if (
|
|
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 (
|
|
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
|
-
|
|
360
|
-
|
|
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
|
-
//
|
|
552
|
-
// release re-arms the counter — a drifted CLI
|
|
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) >=
|
|
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
|
-
|
|
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
|
-
|
|
907
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
217
|
-
|
|
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
|
|
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
|
-
|
|
527
|
-
|
|
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
|
|
@@ -27,6 +27,105 @@ const NO_METRICS_FILE = '.no-metrics';
|
|
|
27
27
|
const ROTATE_MAX_BYTES = 1048576; // 1 MB
|
|
28
28
|
const ROTATE_KEEP_BYTES = 524288; // 512 KB
|
|
29
29
|
|
|
30
|
+
// Unix-only, atomic: refuse to traverse a final-component symlink on the open
|
|
31
|
+
// itself. Undefined on Windows, where the `lstat` / `fstat` checks below are the
|
|
32
|
+
// only layer — same split as the Rust `utils::owned`. Each layer is pinned by
|
|
33
|
+
// its own test (`refuses … with O_NOFOLLOW unavailable` covers the Windows
|
|
34
|
+
// shape); a suite that only pins the pair lets either half be deleted green.
|
|
35
|
+
const O_NOFOLLOW = fs.constants.O_NOFOLLOW || 0;
|
|
36
|
+
|
|
37
|
+
// Keyed by path, not a bare boolean: every hook is a one-shot process today, but
|
|
38
|
+
// a module-global flag would silence the diagnostic for the SECOND project if
|
|
39
|
+
// this is ever required from something long-lived.
|
|
40
|
+
const warnedPaths = new Set();
|
|
41
|
+
|
|
42
|
+
function warnNotOwned(p, why) {
|
|
43
|
+
if (warnedPaths.has(p)) return;
|
|
44
|
+
warnedPaths.add(p);
|
|
45
|
+
try {
|
|
46
|
+
process.stderr.write(`[code-graph] skipping adoption metrics: ${p} ${why}\n`);
|
|
47
|
+
} catch {
|
|
48
|
+
/* stderr closed → nothing to do */
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Reject anything at `p` that is not the ordinary file/directory this module
|
|
54
|
+
* expects to own. JS twin of Rust `utils::owned::refuse_non_regular` /
|
|
55
|
+
* `reject_symlinked_dir`: `.code-graph/` is ordinary repo content, so one
|
|
56
|
+
* `git clone` can carry a symlink where this module expects its own file, and
|
|
57
|
+
* `writeFileSync` / `appendFileSync` both follow it out of the project (audit
|
|
58
|
+
* 2026-09-02 P1-1: a 1.2 MB file outside the tree truncated by the rotator).
|
|
59
|
+
*
|
|
60
|
+
* Symlink is tested FIRST so each rejection gets its own diagnostic, matching
|
|
61
|
+
* `reject_symlinked_dir`. The first cut tested `isDirectory()` first, which is
|
|
62
|
+
* false for a symlink-to-directory — so the symlink arm never ran, the distinct
|
|
63
|
+
* message was dead, and deleting the whole call changed nothing (pre-tag review,
|
|
64
|
+
* P3-1).
|
|
65
|
+
*
|
|
66
|
+
* Absent is fine — the open will create it.
|
|
67
|
+
* @param {string} p absolute path
|
|
68
|
+
* @param {'file'|'dir'} kind
|
|
69
|
+
* @param {fs.Stats} [known] an lstat the caller already took, to avoid a second
|
|
70
|
+
* @returns {boolean} true if `p` is safe to write
|
|
71
|
+
*/
|
|
72
|
+
function isOwnedPath(p, kind, known) {
|
|
73
|
+
let st = known;
|
|
74
|
+
if (!st) {
|
|
75
|
+
try {
|
|
76
|
+
st = fs.lstatSync(p);
|
|
77
|
+
} catch {
|
|
78
|
+
return true; // absent (or unreadable) → leave it to the real syscall
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (st.isSymbolicLink()) {
|
|
82
|
+
warnNotOwned(p, 'is a symlink (following it would write outside the project)');
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
if (kind === 'dir' ? st.isDirectory() : st.isFile()) return true;
|
|
86
|
+
warnNotOwned(p, 'is not a regular file');
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Open `file` for writing only if the thing actually opened is a file this
|
|
92
|
+
* module owns. `fstat` on the returned descriptor — not `lstat` on the path —
|
|
93
|
+
* because that is the only check that describes the object the subsequent write
|
|
94
|
+
* lands on, closing the lstat→open window on the platforms where `O_NOFOLLOW`
|
|
95
|
+
* is 0.
|
|
96
|
+
*
|
|
97
|
+
* `nlink > 1` refuses a HARDLINK. `lstat` reports a hardlinked victim as a
|
|
98
|
+
* plain regular file and `O_NOFOLLOW` says nothing about one, so the symlink
|
|
99
|
+
* guard alone left the identical damage reachable — measured by the pre-tag
|
|
100
|
+
* review at 1,200,020 → 67 bytes, same as the symlink case. `git` cannot
|
|
101
|
+
* deliver a hardlink (its index stores no such mode) but `tar x` can.
|
|
102
|
+
*
|
|
103
|
+
* Callers must NOT pass `O_TRUNC`: truncation would happen on the open, before
|
|
104
|
+
* any of this could look. Truncate through the returned descriptor instead.
|
|
105
|
+
* @returns {number|null} an open fd the caller must close, or null if refused
|
|
106
|
+
*/
|
|
107
|
+
function openOwned(file, flags) {
|
|
108
|
+
const fd = fs.openSync(file, flags | O_NOFOLLOW);
|
|
109
|
+
let st;
|
|
110
|
+
try {
|
|
111
|
+
st = fs.fstatSync(fd);
|
|
112
|
+
} catch (e) {
|
|
113
|
+
fs.closeSync(fd);
|
|
114
|
+
throw e;
|
|
115
|
+
}
|
|
116
|
+
if (!st.isFile()) {
|
|
117
|
+
warnNotOwned(file, 'is not a regular file');
|
|
118
|
+
fs.closeSync(fd);
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
if (st.nlink > 1) {
|
|
122
|
+
warnNotOwned(file, 'has more than one hard link (the write would reach another path)');
|
|
123
|
+
fs.closeSync(fd);
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
return fd;
|
|
127
|
+
}
|
|
128
|
+
|
|
30
129
|
/**
|
|
31
130
|
* Best-effort size-based rotation: if `file` exceeds ROTATE_MAX_BYTES, rewrite
|
|
32
131
|
* it keeping ~the last ROTATE_KEEP_BYTES, trimmed *forward* to the next line
|
|
@@ -42,7 +141,16 @@ function rotateIfNeeded(file) {
|
|
|
42
141
|
const start = Math.max(0, buf.length - ROTATE_KEEP_BYTES);
|
|
43
142
|
const nl = buf.indexOf(0x0a, start); // first newline at/after start
|
|
44
143
|
const trimStart = nl >= 0 ? nl + 1 : start;
|
|
45
|
-
|
|
144
|
+
// No O_TRUNC: it would empty the target on the open, before `openOwned`
|
|
145
|
+
// could refuse it. Truncate through the descriptor once it is vouched for.
|
|
146
|
+
const fd = openOwned(file, fs.constants.O_WRONLY);
|
|
147
|
+
if (fd === null) return;
|
|
148
|
+
try {
|
|
149
|
+
fs.ftruncateSync(fd, 0);
|
|
150
|
+
fs.writeSync(fd, buf.subarray(trimStart));
|
|
151
|
+
} finally {
|
|
152
|
+
fs.closeSync(fd);
|
|
153
|
+
}
|
|
46
154
|
} catch {
|
|
47
155
|
/* missing file or IO error → skip; the append below still runs */
|
|
48
156
|
}
|
|
@@ -58,17 +166,36 @@ function recordRecommendation(cwd, event = {}) {
|
|
|
58
166
|
try {
|
|
59
167
|
const dir = path.join(cwd, '.code-graph');
|
|
60
168
|
// Append-only: do NOT create .code-graph. Its absence means "not an indexed
|
|
61
|
-
// project" — recording there would pollute non-project cwds.
|
|
62
|
-
|
|
169
|
+
// project" — recording there would pollute non-project cwds. `lstat`, not
|
|
170
|
+
// `existsSync`: a symlinked `.code-graph` holding perfectly ordinary files
|
|
171
|
+
// defeats the per-file guard below, because the write then lands on a real
|
|
172
|
+
// regular file that simply is not where this hook thinks it is.
|
|
173
|
+
let dirStat;
|
|
174
|
+
try {
|
|
175
|
+
dirStat = fs.lstatSync(dir);
|
|
176
|
+
} catch {
|
|
177
|
+
return false; // absent → not an indexed project
|
|
178
|
+
}
|
|
179
|
+
if (!isOwnedPath(dir, 'dir', dirStat)) return false;
|
|
63
180
|
// Opt-in metrics silence for dev/dogfood checkouts: when the project marks
|
|
64
181
|
// itself with `.code-graph/.no-metrics`, the tool's own hook/CLI runs (sims,
|
|
65
182
|
// functionality testing) must not self-pollute its adoption metrics. Mirrors
|
|
66
183
|
// the Rust cli::record_cli_use guard. Reversible: delete the file to re-enable.
|
|
67
184
|
if (fs.existsSync(path.join(dir, NO_METRICS_FILE))) return false;
|
|
68
185
|
const file = path.join(dir, REC_FILE);
|
|
186
|
+
if (!isOwnedPath(file, 'file')) return false;
|
|
69
187
|
rotateIfNeeded(file); // rotate-before-append so the file never exceeds ~max + one line
|
|
70
188
|
const line = JSON.stringify({ ts: new Date().toISOString(), ...event }) + '\n';
|
|
71
|
-
|
|
189
|
+
const fd = openOwned(
|
|
190
|
+
file,
|
|
191
|
+
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND
|
|
192
|
+
);
|
|
193
|
+
if (fd === null) return false;
|
|
194
|
+
try {
|
|
195
|
+
fs.writeSync(fd, line);
|
|
196
|
+
} finally {
|
|
197
|
+
fs.closeSync(fd);
|
|
198
|
+
}
|
|
72
199
|
return true;
|
|
73
200
|
} catch {
|
|
74
201
|
return false;
|
|
@@ -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)
|
|
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.
|
|
38
|
+
npx -y -p @sdsrs/code-graph@0.131.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.
|
|
3
|
+
"version": "0.131.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.
|
|
39
|
-
"@sdsrs/code-graph-linux-arm64": "0.
|
|
40
|
-
"@sdsrs/code-graph-darwin-x64": "0.
|
|
41
|
-
"@sdsrs/code-graph-darwin-arm64": "0.
|
|
42
|
-
"@sdsrs/code-graph-win32-x64": "0.
|
|
38
|
+
"@sdsrs/code-graph-linux-x64": "0.131.0",
|
|
39
|
+
"@sdsrs/code-graph-linux-arm64": "0.131.0",
|
|
40
|
+
"@sdsrs/code-graph-darwin-x64": "0.131.0",
|
|
41
|
+
"@sdsrs/code-graph-darwin-arm64": "0.131.0",
|
|
42
|
+
"@sdsrs/code-graph-win32-x64": "0.131.0"
|
|
43
43
|
}
|
|
44
44
|
}
|