@sdsrs/code-graph 0.56.2 → 0.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.56.2",
7
+ "version": "0.58.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -3,6 +3,7 @@
3
3
  const { execFileSync } = require('child_process');
4
4
  const fs = require('fs');
5
5
  const https = require('https');
6
+ const crypto = require('crypto');
6
7
  const path = require('path');
7
8
  const os = require('os');
8
9
  const { CACHE_DIR, PLUGIN_ID, MARKETPLACE_NAME, readManifest, readJson, writeJsonAtomic, installedPluginsPath, pluginsCacheDir } = require('./lifecycle');
@@ -240,18 +241,59 @@ async function downloadBinary(latest) {
240
241
  latest.binaryUrl,
241
242
  ], { timeout: 60000, stdio: 'pipe' });
242
243
 
243
- return promoteVerifiedBinary(binaryTmp, binaryDst, latest.version);
244
+ // Best-effort fetch of the integrity sidecar (<asset>.sha256). curl -f makes
245
+ // a 404 (an older release with no sidecar) fail → expectedSha stays null →
246
+ // promoteVerifiedBinary takes the TOFU path. Same-origin, so this guards
247
+ // corruption in transit, not a release-asset swap (version-exec is the
248
+ // backstop there).
249
+ let expectedSha = null;
250
+ const shaTmp = binaryTmp + '.sha256';
251
+ try {
252
+ execFileSync('curl', ['-sfL', '-o', shaTmp, latest.binaryUrl + '.sha256'],
253
+ { timeout: 30000, stdio: 'pipe' });
254
+ expectedSha = (fs.readFileSync(shaTmp, 'utf8').trim().split(/\s+/)[0]) || null;
255
+ } catch { /* no sidecar → TOFU */ } finally {
256
+ try { if (fs.existsSync(shaTmp)) fs.unlinkSync(shaTmp); } catch { /* ok */ }
257
+ }
258
+
259
+ return promoteVerifiedBinary(binaryTmp, binaryDst, latest.version, expectedSha);
244
260
  } catch (e) {
245
261
  console.error(`[code-graph] Binary download failed: ${e.message}`);
246
262
  return false;
247
263
  }
248
264
  }
249
265
 
250
- function promoteVerifiedBinary(binaryTmp, binaryDst, expectedVersion) {
266
+ /**
267
+ * Hex sha256 of a file's contents (lowercase).
268
+ * @param {string} filePath
269
+ * @returns {string}
270
+ */
271
+ function sha256File(filePath) {
272
+ return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
273
+ }
274
+
275
+ function promoteVerifiedBinary(binaryTmp, binaryDst, expectedVersion, expectedSha256) {
251
276
  try {
252
277
  const stat = fs.statSync(binaryTmp);
253
278
  if (stat.size <= 1_000_000) return false;
254
279
 
280
+ // Integrity gate BEFORE the file is made executable or run, so a corrupted
281
+ // or tampered download is never exec'd. The published <asset>.sha256 sidecar
282
+ // is same-origin, so this defends transit/CDN corruption + truncation, not a
283
+ // full release compromise (an attacker swapping the binary swaps the sidecar
284
+ // too — the version-exec check below is the backstop there). No sidecar
285
+ // (older release) → warn + proceed (TOFU), preserving the size + version
286
+ // gates. Mirrors the snapshot checksum convention (src/snapshot/install.rs).
287
+ if (expectedSha256) {
288
+ const actualSha = sha256File(binaryTmp);
289
+ if (actualSha.toLowerCase() !== String(expectedSha256).toLowerCase()) {
290
+ console.error(`[code-graph] Binary checksum mismatch (sha256): expected ${expectedSha256}, got ${actualSha} — refusing to install.`);
291
+ return false;
292
+ }
293
+ } else {
294
+ console.error('[code-graph] No binary checksum sidecar found — content not verified (size + version checks still apply).');
295
+ }
296
+
255
297
  // chmod BEFORE reading the version. readBinaryVersion executes the binary
256
298
  // (`--version`), which requires the exec bit; `curl -o` writes the tmp file
257
299
  // as 0644 (no exec bit), so reading the version first fails with EACCES →
@@ -4,6 +4,7 @@ const assert = require('node:assert/strict');
4
4
  const fs = require('fs');
5
5
  const os = require('os');
6
6
  const path = require('path');
7
+ const crypto = require('crypto');
7
8
 
8
9
  const {
9
10
  commandExists,
@@ -92,6 +93,41 @@ test('promoteVerifiedBinary promotes a non-executable (0644) download — curl -
92
93
  assert.equal(readBinaryVersion(dst), '1.2.3');
93
94
  });
94
95
 
96
+ test('promoteVerifiedBinary accepts a binary matching the expected sha256', (t) => {
97
+ const dir = mkDir(t, 'code-graph-bin-');
98
+ const tmp = path.join(dir, 'code-graph-mcp.tmp');
99
+ const dst = path.join(dir, 'code-graph-mcp');
100
+ writeFakeBinary(tmp, '1.2.3');
101
+ const sha = crypto.createHash('sha256').update(fs.readFileSync(tmp)).digest('hex');
102
+ assert.equal(promoteVerifiedBinary(tmp, dst, '1.2.3', sha), true);
103
+ assert.equal(fs.existsSync(dst), true);
104
+ });
105
+
106
+ test('promoteVerifiedBinary rejects a binary whose sha256 mismatches the sidecar', (t) => {
107
+ // Tampered/corrupted download: the checksum gate runs BEFORE chmod+exec, so a
108
+ // mismatched binary is refused and never made executable. Platform-independent
109
+ // (no exec needed to reject).
110
+ const dir = mkDir(t, 'code-graph-bin-');
111
+ const tmp = path.join(dir, 'code-graph-mcp.tmp');
112
+ const dst = path.join(dir, 'code-graph-mcp');
113
+ writeFakeBinary(tmp, '1.2.3');
114
+ const wrongSha = 'deadbeef'.repeat(8); // 64 hex chars, deliberately wrong
115
+ assert.equal(promoteVerifiedBinary(tmp, dst, '1.2.3', wrongSha), false);
116
+ assert.equal(fs.existsSync(dst), false, 'tampered binary must not be promoted');
117
+ assert.equal(fs.existsSync(tmp), false, 'tmp cleaned up on rejection');
118
+ });
119
+
120
+ test('promoteVerifiedBinary proceeds without a sidecar (TOFU back-compat)', (t) => {
121
+ // Older releases ship no <asset>.sha256; a null expected hash must not block
122
+ // install (the size + version-exec gates still apply).
123
+ const dir = mkDir(t, 'code-graph-bin-');
124
+ const tmp = path.join(dir, 'code-graph-mcp.tmp');
125
+ const dst = path.join(dir, 'code-graph-mcp');
126
+ writeFakeBinary(tmp, '1.2.3');
127
+ assert.equal(promoteVerifiedBinary(tmp, dst, '1.2.3', null), true);
128
+ assert.equal(fs.existsSync(dst), true);
129
+ });
130
+
95
131
  test('cachedBinaryNeedsUpdate is version-aware, not existence-only', (t) => {
96
132
  const dir = mkDir(t, 'code-graph-heal-');
97
133
  const binaryPath = path.join(dir, 'code-graph-mcp');
@@ -79,7 +79,12 @@ function sanitizeSearchPath(searchPath) {
79
79
  * @param {number} [opts.maxBytes]
80
80
  * @returns {{status: 'hits', text: string, truncated: boolean}
81
81
  * | {status: 'no-hits'}
82
+ * | {status: 'no-binary'}
82
83
  * | {status: 'unavailable'}}
84
+ * `no-binary` (binary not installed / not locatable) is kept distinct from
85
+ * `unavailable` (runtime failure) so the deny funnel can tell "flagship
86
+ * answer-in-deny is dark because the binary is missing" apart from "binary
87
+ * ran, query just had no hits". Both still fall back to the static deny.
83
88
  */
84
89
  function runGrepAnswer(opts = {}) {
85
90
  const {
@@ -97,7 +102,7 @@ function runGrepAnswer(opts = {}) {
97
102
  if (binary === undefined) {
98
103
  binary = process.env._CG_ANSWER_BINARY || require('./find-binary').findBinary();
99
104
  }
100
- if (!binary) return { status: 'unavailable' };
105
+ if (!binary) return { status: 'no-binary' };
101
106
 
102
107
  // Defensive re-sanitize: callers should pass a clean path, but a glob
103
108
  // reaching argv is a guaranteed nonzero exit (see sanitizeSearchPath).
@@ -143,6 +148,12 @@ function runGrepAnswer(opts = {}) {
143
148
  * context-flag greps: the model wants to READ the functions, so hand it the
144
149
  * functions). Same bounded/best-effort posture as runGrepAnswer; symbols that
145
150
  * fail to resolve are skipped, all-fail → no-hits (caller falls back to grep).
151
+ * @returns {{status: 'hits', text: string, truncated: boolean}
152
+ * | {status: 'no-hits'}
153
+ * | {status: 'no-binary'}
154
+ * | {status: 'unavailable'}}
155
+ * `no-binary` distinguishes a missing/unlocatable binary from a runtime
156
+ * `unavailable`, so the deny funnel can see a dark flagship answer-in-deny.
146
157
  */
147
158
  function runShowAnswer(opts = {}) {
148
159
  const {
@@ -159,7 +170,7 @@ function runShowAnswer(opts = {}) {
159
170
  if (binary === undefined) {
160
171
  binary = process.env._CG_ANSWER_BINARY || require('./find-binary').findBinary();
161
172
  }
162
- if (!binary) return { status: 'unavailable' };
173
+ if (!binary) return { status: 'no-binary' };
163
174
 
164
175
  const parts = [];
165
176
  for (const sym of symbols.slice(0, 3)) {
@@ -189,6 +200,12 @@ function runShowAnswer(opts = {}) {
189
200
  * v0.49 — Run `code-graph-mcp overview <dir>` for the read-fanout hint, so the
190
201
  * hint DELIVERS the module map instead of advising a tool call (hints measured
191
202
  * 0/40 transfer on 2026-06-12; delivered answers satisfied 5/5 in place).
203
+ * @returns {{status: 'hits', text: string, truncated: boolean}
204
+ * | {status: 'no-hits'}
205
+ * | {status: 'no-binary'}
206
+ * | {status: 'unavailable'}}
207
+ * `no-binary` distinguishes a missing/unlocatable binary from a runtime
208
+ * `unavailable`, so the read-fanout funnel can see a dark delivered hint.
192
209
  */
193
210
  function runOverviewAnswer(opts = {}) {
194
211
  const {
@@ -205,7 +222,7 @@ function runOverviewAnswer(opts = {}) {
205
222
  if (binary === undefined) {
206
223
  binary = process.env._CG_ANSWER_BINARY || require('./find-binary').findBinary();
207
224
  }
208
- if (!binary) return { status: 'unavailable' };
225
+ if (!binary) return { status: 'no-binary' };
209
226
  const res = spawnSync(binary, ['overview', dir], {
210
227
  cwd,
211
228
  timeout: timeoutMs,
@@ -4,7 +4,7 @@ const assert = require('node:assert/strict');
4
4
  const fs = require('fs');
5
5
  const os = require('os');
6
6
  const path = require('path');
7
- const { runGrepAnswer, runShowAnswer, truncateAtLine } = require('./cg-answer');
7
+ const { runGrepAnswer, runShowAnswer, runOverviewAnswer, truncateAtLine } = require('./cg-answer');
8
8
 
9
9
  // Stub "binary": a node script that reacts to its first real arg so one stub
10
10
  // covers hits / no-hits / error / timeout cases.
@@ -95,12 +95,15 @@ test('runGrepAnswer: exit >1 → unavailable', () => {
95
95
  assert.equal(r.status, 'unavailable');
96
96
  });
97
97
 
98
- test('runGrepAnswer: missing binary → unavailable', () => {
98
+ test('runGrepAnswer: missing binary → no-binary (distinct from runtime unavailable)', () => {
99
99
  const r = runGrepAnswer({ cwd: stubDir, pattern: 'fts5_search', binary: null });
100
- assert.equal(r.status, 'unavailable');
100
+ assert.equal(r.status, 'no-binary',
101
+ 'a null binary is the flagship-dark case and must be distinguishable from a runtime failure');
101
102
  });
102
103
 
103
- test('runGrepAnswer: nonexistent binary path → unavailable', () => {
104
+ test('runGrepAnswer: nonexistent binary path → unavailable (spawn failure, not no-binary)', () => {
105
+ // A non-null path that fails to spawn is a runtime failure, NOT a missing
106
+ // binary — `no-binary` is reserved for findBinary() returning falsy.
104
107
  const r = runGrepAnswer({
105
108
  cwd: stubDir, pattern: 'fts5_search', binary: path.join(stubDir, 'nope-bin'),
106
109
  });
@@ -213,3 +216,38 @@ test('runShowAnswer: failing binary → no-hits (caller falls back to grep answe
213
216
  const r = runShowAnswer({ cwd: stubDir, symbols: ['ExplodePlease'], binary: stubBinary() });
214
217
  assert.equal(r.status, 'no-hits');
215
218
  });
219
+
220
+ test('runShowAnswer: missing binary → no-binary (distinct from runtime no-hits/unavailable)', () => {
221
+ const r = runShowAnswer({ cwd: stubDir, symbols: ['alpha_one'], binary: null });
222
+ assert.equal(r.status, 'no-binary');
223
+ });
224
+
225
+ // ── runOverviewAnswer (v0.49) — read-fanout delivered module map ──────
226
+
227
+ test('runOverviewAnswer: hits → status hits with stdout text', () => {
228
+ const r = runOverviewAnswer({ cwd: stubDir, dir: 'src/storage', binary: stubBinary() });
229
+ assert.equal(r.status, 'hits');
230
+ assert.match(r.text, /args=\["overview","src\/storage"\]/);
231
+ });
232
+
233
+ test('runOverviewAnswer: CLI "No matches" → no-hits', () => {
234
+ const r = runOverviewAnswer({ cwd: stubDir, dir: 'NothingHere', binary: stubBinary() });
235
+ assert.equal(r.status, 'no-hits');
236
+ });
237
+
238
+ test('runOverviewAnswer: failing binary → unavailable', () => {
239
+ const r = runOverviewAnswer({ cwd: stubDir, dir: 'ExplodePlease', binary: stubBinary() });
240
+ assert.equal(r.status, 'unavailable');
241
+ });
242
+
243
+ test('runOverviewAnswer: missing binary → no-binary (distinct from runtime unavailable)', () => {
244
+ const r = runOverviewAnswer({ cwd: stubDir, dir: 'src/storage', binary: null });
245
+ assert.equal(r.status, 'no-binary');
246
+ });
247
+
248
+ test('runOverviewAnswer: empty/oversized dir → unavailable (never spawns)', () => {
249
+ assert.equal(runOverviewAnswer({ cwd: stubDir, dir: '', binary: stubBinary() }).status, 'unavailable');
250
+ assert.equal(
251
+ runOverviewAnswer({ cwd: stubDir, dir: 'a'.repeat(301), binary: stubBinary() }).status,
252
+ 'unavailable');
253
+ });
@@ -61,6 +61,11 @@ function runDiagnostics() {
61
61
  results.push({ name: 'Schema', status: 'skip', detail: 'binary not found' });
62
62
  results.push({ name: 'Index', status: 'skip', detail: 'binary not found' });
63
63
  results.push({ name: 'Embeddings', status: 'skip', detail: 'binary not found' });
64
+ // The deny hooks run `code-graph-mcp grep/show/overview` inside the hook to
65
+ // answer in-place (the flagship conversion lever). A missing binary silently
66
+ // disables that — denies fall back to bare advice — so call it out here.
67
+ results.push({ name: 'Answer-in-deny', status: 'skip',
68
+ detail: 'disabled — binary not found, deny hooks fall back to static advice' });
64
69
  } else {
65
70
  let execOk = true;
66
71
  try {
@@ -483,7 +483,13 @@ function runMain() {
483
483
  return;
484
484
  }
485
485
 
486
- if (isOnCooldown(rawCmd)) return;
486
+ if (isOnCooldown(rawCmd)) {
487
+ // Outcome proxy: a source grep re-issued within the cooldown window runs
488
+ // silently (no deny/hint). Record it so `stats` sees the model's grep
489
+ // fan-out — especially a re-grep right after cg answered the same query.
490
+ recordRecommendation(root, { hook: 'grep', action: 'observe' });
491
+ return;
492
+ }
487
493
 
488
494
  markCooldown(rawCmd);
489
495
 
@@ -532,6 +538,11 @@ function runMain() {
532
538
  hook: 'grep', action: 'deny', answered,
533
539
  // mode segments which answer type converts (show=bodies, grep=hits).
534
540
  ...(answered ? { mode: answeredMode } : {}),
541
+ // reason segments WHY an unanswered deny fell back to the static copy:
542
+ // 'no-binary' (flagship answer-in-deny dark — binary missing) vs
543
+ // 'unavailable' (binary ran but failed/timed out). Without this the two
544
+ // are indistinguishable in the funnel ("broken" looks like "no hits").
545
+ ...(answered ? {} : { reason: answer.status }),
535
546
  // tail segments compound-command denies — lets the funnel compare
536
547
  // re-issue behavior for denies that carried a tail note.
537
548
  ...(unansweredTail ? { tail: true } : {}),
@@ -885,6 +885,9 @@ test('e2e: denied grep with stub hits → deny JSON embeds the answer + records
885
885
  const rec = JSON.parse(recs.trim().split('\n').pop());
886
886
  assert.equal(rec.action, 'deny');
887
887
  assert.equal(rec.answered, true);
888
+ // An answered deny carries no failure reason — the field is reserved for
889
+ // the not-answered fallback so 'no-binary' vs 'unavailable' stays legible.
890
+ assert.equal(rec.reason, undefined);
888
891
  } finally {
889
892
  cleanupFixture(fixture, cmd);
890
893
  }
@@ -926,6 +929,9 @@ test('e2e: stub fails → static deny (v0.46 fallback) + records answered:false'
926
929
  pathE2e.join(fixture.dir, '.code-graph', 'recommendations.jsonl'), 'utf8').trim());
927
930
  assert.equal(rec.action, 'deny');
928
931
  assert.equal(rec.answered, false);
932
+ // A binary that ran but failed (exit 3) is a runtime 'unavailable' — the
933
+ // funnel must NOT confuse this with a missing-binary ('no-binary') deny.
934
+ assert.equal(rec.reason, 'unavailable');
929
935
  } finally {
930
936
  cleanupFixture(fixture, cmd);
931
937
  }
@@ -1045,6 +1051,59 @@ test('e2e: simple (non-compound) denied grep → no tail field in the deny recor
1045
1051
  }
1046
1052
  });
1047
1053
 
1054
+ test('e2e: missing binary → static deny records reason:no-binary (flagship-dark, distinct from no-hits & unavailable)', () => {
1055
+ // The whole point of the `reason` field: a deny that fell back because the
1056
+ // binary could not be found ('no-binary') must be distinguishable in the log
1057
+ // from one where the binary ran but had nothing useful. We can't make
1058
+ // findBinary() return null in-repo (dev target/release is always there), so
1059
+ // run the child with a `--require` shim that forces it null — and DON'T set
1060
+ // _CG_ANSWER_BINARY (it would short-circuit before findBinary()).
1061
+ const uniq = `StubGone${Date.now()}`;
1062
+ const fixture = e2eFixture(`process.stdout.write('unused\\n');`);
1063
+ const shim = pathE2e.join(fixture.dir, 'no-binary-shim.js');
1064
+ fsE2e.writeFileSync(shim, `
1065
+ const Module = require('module');
1066
+ const orig = Module.prototype.require;
1067
+ Module.prototype.require = function (id) {
1068
+ const m = orig.apply(this, arguments);
1069
+ if (id === './find-binary') {
1070
+ return new Proxy(m, { get(t, p) { return p === 'findBinary' ? () => null : t[p]; } });
1071
+ }
1072
+ return m;
1073
+ };
1074
+ `);
1075
+ const cmd = `grep -rn "${uniq}" src/`;
1076
+ try {
1077
+ const res = spawnHook(process.execPath, [pathE2e.join(__dirname, 'pre-grep-guide.js')], {
1078
+ cwd: fixture.dir,
1079
+ input: JSON.stringify({ tool_input: { command: cmd } }),
1080
+ encoding: 'utf8',
1081
+ env: {
1082
+ ...process.env,
1083
+ // _CG_ANSWER_BINARY intentionally UNSET so the shimmed findBinary() runs.
1084
+ _CG_ANSWER_BINARY: '',
1085
+ NODE_OPTIONS: `--require ${shim}`,
1086
+ CODE_GRAPH_QUIET_HOOKS: '0',
1087
+ CODE_GRAPH_NO_BLOCK_GREP: '0',
1088
+ CODE_GRAPH_NO_ANSWER_IN_DENY: '0',
1089
+ },
1090
+ });
1091
+ assert.equal(res.status, 0);
1092
+ const out = JSON.parse(res.stdout);
1093
+ // Still a deny, still the static fallback copy (no embedded answer).
1094
+ assert.equal(out.hookSpecificOutput.permissionDecision, 'deny');
1095
+ assert.match(out.hookSpecificOutput.permissionDecisionReason, /denied by code-graph hook/);
1096
+ const rec = JSON.parse(fsE2e.readFileSync(
1097
+ pathE2e.join(fixture.dir, '.code-graph', 'recommendations.jsonl'), 'utf8').trim());
1098
+ assert.equal(rec.action, 'deny');
1099
+ assert.equal(rec.answered, false);
1100
+ assert.equal(rec.reason, 'no-binary',
1101
+ 'a missing-binary deny must be distinguishable from an unavailable (runtime-fail) deny');
1102
+ } finally {
1103
+ cleanupFixture(fixture, cmd);
1104
+ }
1105
+ });
1106
+
1048
1107
  // ── v0.48 subdir-cwd dark fix: resolveProjectRoot / rebaseRelativePaths ──
1049
1108
  // daagu 2026-06-11: the persistent shell `cd backend/` darkened 38/40
1050
1109
  // head-greps for the rest of the night — gate 5 checked process.cwd() only.
@@ -164,14 +164,27 @@ function trackReadAndMaybeHint(root, rel, now = Date.now()) {
164
164
  fired = true;
165
165
  }
166
166
  saveState(root, state);
167
- if (!fired) return false;
167
+ if (!fired) {
168
+ // Outcome proxy: a source read that didn't trip the fanout hint still ran.
169
+ // Record it (best-effort) so `stats` can measure the model's read fan-out —
170
+ // e.g. a read right after cg answered a grep in-place (search-decay).
171
+ recordRecommendation(root, { hook: 'read', action: 'observe' });
172
+ return false;
173
+ }
168
174
 
169
175
  let answer = { status: 'unavailable' };
170
176
  if (!isAnswerDisabled()) {
171
177
  answer = runOverviewAnswer({ cwd: root, dir });
172
178
  }
173
179
  const answered = answer.status === 'hits';
174
- recordRecommendation(root, { hook: 'read', action: 'hint', answered });
180
+ recordRecommendation(root, {
181
+ hook: 'read', action: 'hint', answered,
182
+ // reason segments WHY an unanswered hint fell back to the bare advice:
183
+ // 'no-binary' (delivered overview dark — binary missing) vs 'unavailable'
184
+ // (binary ran but failed/timed out) vs 'no-hits'. Mirrors pre-grep-guide
185
+ // so the read-fanout funnel can tell a dark flagship apart from no result.
186
+ ...(answered ? {} : { reason: answer.status }),
187
+ });
175
188
  process.stdout.write((answered ? buildHintWithAnswer(dir, answer) : buildHint(dir)) + '\n');
176
189
  return true;
177
190
  }
@@ -291,6 +291,58 @@ test('trackReadAndMaybeHint: fires on 5th read with stubbed overview answer', ()
291
291
  }
292
292
  });
293
293
 
294
+ test('trackReadAndMaybeHint: missing binary → hint records reason:no-binary (delivered-overview dark, sibling of pre-grep)', () => {
295
+ // Sibling-hook parity: when the binary can't be found the read-fanout hint
296
+ // falls back to bare advice. That must be distinguishable in the funnel from a
297
+ // runtime failure, exactly like pre-grep's deny. Force findBinary() null
298
+ // in-process and unset _CG_ANSWER_BINARY so the resolution actually runs.
299
+ const findBinaryMod = require('./find-binary');
300
+ const realFindBinary = findBinaryMod.findBinary;
301
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'readfan-nobin-'));
302
+ fs.mkdirSync(path.join(root, '.code-graph'), { recursive: true });
303
+ const oldEnv = process.env._CG_ANSWER_BINARY;
304
+ delete process.env._CG_ANSWER_BINARY;
305
+ findBinaryMod.findBinary = () => null;
306
+ const origWrite = process.stdout.write.bind(process.stdout);
307
+ process.stdout.write = () => true;
308
+ try {
309
+ let fired = false;
310
+ for (let i = 0; i < 5; i++) {
311
+ fired = trackReadAndMaybeHint(root, 'src/storage/file' + i + '.rs');
312
+ }
313
+ assert.equal(fired, true, '5th same-dir read must still fire the hint');
314
+ const recs = fs.readFileSync(path.join(root, '.code-graph', 'recommendations.jsonl'), 'utf8');
315
+ const last = JSON.parse(recs.trim().split('\n').pop());
316
+ assert.equal(last.action, 'hint');
317
+ assert.equal(last.answered, false);
318
+ assert.equal(last.reason, 'no-binary',
319
+ 'a dark delivered-overview hint must be distinguishable from a runtime failure');
320
+ } finally {
321
+ process.stdout.write = origWrite;
322
+ findBinaryMod.findBinary = realFindBinary;
323
+ if (oldEnv === undefined) delete process.env._CG_ANSWER_BINARY;
324
+ else process.env._CG_ANSWER_BINARY = oldEnv;
325
+ fs.rmSync(root, { recursive: true, force: true });
326
+ }
327
+ });
328
+
329
+ test('trackReadAndMaybeHint: non-fanout source read records an observe event', () => {
330
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'readfan-observe-'));
331
+ fs.mkdirSync(path.join(root, '.code-graph'), { recursive: true });
332
+ try {
333
+ // A single subdir source read is below the fanout threshold → no hint, but
334
+ // it must still record an `observe` event for the search-decay metric.
335
+ const fired = trackReadAndMaybeHint(root, 'src/storage/db.rs');
336
+ assert.equal(fired, false, 'single read must not fire the fanout hint');
337
+ const recs = fs.readFileSync(path.join(root, '.code-graph', 'recommendations.jsonl'), 'utf8');
338
+ const last = JSON.parse(recs.trim().split('\n').pop());
339
+ assert.equal(last.hook, 'read');
340
+ assert.equal(last.action, 'observe');
341
+ } finally {
342
+ fs.rmSync(root, { recursive: true, force: true });
343
+ }
344
+ });
345
+
294
346
  test('trackReadAndMaybeHint: top-level and outside-root paths never fire', () => {
295
347
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'readfan-skip-'));
296
348
  try {
@@ -18,6 +18,33 @@ const path = require('path');
18
18
 
19
19
  const REC_FILE = 'recommendations.jsonl';
20
20
 
21
+ // Bounded growth: recommendations.jsonl is append-only and written per-event
22
+ // from BOTH here and the Rust CLI (cli::record_cli_use). Keep these constants in
23
+ // sync with the Rust side (mcp::metrics::JSONL_ROTATE_MAX_BYTES / KEEP_BYTES).
24
+ const ROTATE_MAX_BYTES = 1048576; // 1 MB
25
+ const ROTATE_KEEP_BYTES = 524288; // 512 KB
26
+
27
+ /**
28
+ * Best-effort size-based rotation: if `file` exceeds ROTATE_MAX_BYTES, rewrite
29
+ * it keeping ~the last ROTATE_KEEP_BYTES, trimmed *forward* to the next line
30
+ * boundary so no partial line survives. Swallows every error — telemetry
31
+ * rotation must never break or delay a tool call. Mirror of the Rust
32
+ * `mcp::metrics::rotate_jsonl_if_over`.
33
+ * @param {string} file absolute path to the JSONL file
34
+ */
35
+ function rotateIfNeeded(file) {
36
+ try {
37
+ if (fs.statSync(file).size <= ROTATE_MAX_BYTES) return;
38
+ const buf = fs.readFileSync(file);
39
+ const start = Math.max(0, buf.length - ROTATE_KEEP_BYTES);
40
+ const nl = buf.indexOf(0x0a, start); // first newline at/after start
41
+ const trimStart = nl >= 0 ? nl + 1 : start;
42
+ fs.writeFileSync(file, buf.subarray(trimStart));
43
+ } catch {
44
+ /* missing file or IO error → skip; the append below still runs */
45
+ }
46
+ }
47
+
21
48
  /**
22
49
  * Append one recommendation event to <cwd>/.code-graph/recommendations.jsonl.
23
50
  * @param {string} cwd project root (the hook's process.cwd())
@@ -30,8 +57,10 @@ function recordRecommendation(cwd, event = {}) {
30
57
  // Append-only: do NOT create .code-graph. Its absence means "not an indexed
31
58
  // project" — recording there would pollute non-project cwds.
32
59
  if (!fs.existsSync(dir)) return false;
60
+ const file = path.join(dir, REC_FILE);
61
+ rotateIfNeeded(file); // rotate-before-append so the file never exceeds ~max + one line
33
62
  const line = JSON.stringify({ ts: new Date().toISOString(), ...event }) + '\n';
34
- fs.appendFileSync(path.join(dir, REC_FILE), line);
63
+ fs.appendFileSync(file, line);
35
64
  return true;
36
65
  } catch {
37
66
  return false;
@@ -42,3 +42,25 @@ test('recordRecommendation appends across calls (one line each)', (t) => {
42
42
  const hooks = lines.map((l) => JSON.parse(l).hook);
43
43
  assert.deepEqual(hooks, ['grep', 'read', 'grep']);
44
44
  });
45
+
46
+ test('recordRecommendation rotates the file when it exceeds the size cap', (t) => {
47
+ const cwd = tmpProject(t, true);
48
+ const file = path.join(cwd, '.code-graph', REC_FILE);
49
+ // Pre-fill > 1MB of prior events.
50
+ const filler = 'y'.repeat(1024);
51
+ let blob = '';
52
+ for (let i = 0; i < 1200; i++) blob += `{"old":${i},"pad":"${filler}"}\n`;
53
+ fs.writeFileSync(file, blob);
54
+ assert.ok(fs.statSync(file).size > 1048576, 'precondition: file over 1MB');
55
+
56
+ // One more recorded event must trigger rotation (rotate-before-append).
57
+ assert.equal(recordRecommendation(cwd, { hook: 'grep', action: 'deny' }), true);
58
+
59
+ const size = fs.statSync(file).size;
60
+ assert.ok(size < 600000, `rotated file should be well under 1MB, got ${size}`);
61
+ const lines = fs.readFileSync(file, 'utf8').trim().split('\n');
62
+ // The just-recorded line is last and intact; the first surviving line is whole JSON.
63
+ const last = JSON.parse(lines[lines.length - 1]);
64
+ assert.equal(last.action, 'deny');
65
+ assert.doesNotThrow(() => JSON.parse(lines[0]), 'first surviving line must be a whole JSON line');
66
+ });
@@ -379,6 +379,13 @@ function determineQueryType(intents, symbols, filePaths, isCoolingDownFn, messag
379
379
  // db presence) live INSIDE this guard so `require()` from tests doesn't
380
380
  // terminate the test process on module load.
381
381
  function runMain() {
382
+ // Escape hatch first: CODE_GRAPH_QUIET_HOOKS=1 must silence EVERYTHING,
383
+ // including the mid-session install notice below. On a fresh install (no
384
+ // manifest) that notice otherwise leaks to stdout even under quiet — and any
385
+ // stdout/stderr leak lands in Claude's display. (The dev box has a manifest,
386
+ // so this only surfaced once user-prompt-context.test.js ran in CI.)
387
+ if (computeQuietHooks()) return;
388
+
382
389
  // Mid-session install: lifecycle.js install() hasn't run yet (no manifest).
383
390
  // MCP server only starts at session startup — tell the user to restart.
384
391
  if (!fs.existsSync(MANIFEST_PATH)) {
@@ -398,8 +405,6 @@ function runMain() {
398
405
  return;
399
406
  }
400
407
 
401
- if (computeQuietHooks()) return;
402
-
403
408
  // --- Read user message ---
404
409
  let message;
405
410
  try {
@@ -558,6 +558,33 @@ test('CODE_GRAPH_QUIET_HOOKS=1 short-circuits silently on stdout, stderr, exit 0
558
558
  assert.equal(proc.status, 0, 'quiet must exit 0');
559
559
  });
560
560
 
561
+ test('CODE_GRAPH_QUIET_HOOKS=1 silences even the fresh-install (no-manifest) notice', () => {
562
+ // Regression: the mid-session install notice printed BEFORE the quiet check,
563
+ // so on a fresh checkout (no ~/.cache/code-graph manifest) it leaked to stdout
564
+ // despite the escape hatch. CI hit this; the dev box has a manifest and masked
565
+ // it. Force the no-manifest path with a throwaway HOME so it reproduces locally.
566
+ const { spawnSync } = require('node:child_process');
567
+ const os = require('node:os');
568
+ const fs = require('node:fs');
569
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-upc-nohome-'));
570
+ try {
571
+ const script = path.join(__dirname, 'user-prompt-context.js');
572
+ const proc = spawnSync(process.execPath, [script], {
573
+ input: JSON.stringify({ message: 'impact of refactoring parse_code function' }),
574
+ // HOME (POSIX) + USERPROFILE (Windows) → os.homedir() points at an empty
575
+ // dir, so MANIFEST_PATH is absent and runMain() enters the install branch.
576
+ env: { ...process.env, HOME: home, USERPROFILE: home, CODE_GRAPH_QUIET_HOOKS: '1' },
577
+ encoding: 'utf8',
578
+ timeout: 2000,
579
+ });
580
+ assert.equal(proc.stdout, '', 'quiet must silence the no-manifest install notice on stdout');
581
+ assert.equal(proc.stderr, '', 'quiet must be silent on stderr');
582
+ assert.equal(proc.status, 0, 'quiet must exit 0');
583
+ } finally {
584
+ fs.rmSync(home, { recursive: true, force: true });
585
+ }
586
+ });
587
+
561
588
  // ── Phase E: hasSymptom + symptom-hint fallback ──────────────
562
589
 
563
590
  const { hasSymptom, SYMPTOM_PATTERNS } = require('./user-prompt-context');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/code-graph",
3
- "version": "0.56.2",
3
+ "version": "0.58.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.56.2",
39
- "@sdsrs/code-graph-linux-arm64": "0.56.2",
40
- "@sdsrs/code-graph-darwin-x64": "0.56.2",
41
- "@sdsrs/code-graph-darwin-arm64": "0.56.2",
42
- "@sdsrs/code-graph-win32-x64": "0.56.2"
38
+ "@sdsrs/code-graph-linux-x64": "0.58.0",
39
+ "@sdsrs/code-graph-linux-arm64": "0.58.0",
40
+ "@sdsrs/code-graph-darwin-x64": "0.58.0",
41
+ "@sdsrs/code-graph-darwin-arm64": "0.58.0",
42
+ "@sdsrs/code-graph-win32-x64": "0.58.0"
43
43
  }
44
44
  }