@sdsrs/code-graph 0.56.2 → 0.57.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/claude-plugin/.claude-plugin/plugin.json +1 -1
- package/claude-plugin/scripts/auto-update.js +44 -2
- package/claude-plugin/scripts/auto-update.test.js +36 -0
- package/claude-plugin/scripts/cg-answer.js +20 -3
- package/claude-plugin/scripts/cg-answer.test.js +42 -4
- package/claude-plugin/scripts/doctor.js +5 -0
- package/claude-plugin/scripts/pre-grep-guide.js +12 -1
- package/claude-plugin/scripts/pre-grep-guide.test.js +59 -0
- package/claude-plugin/scripts/pre-read-guide.js +15 -2
- package/claude-plugin/scripts/pre-read-guide.test.js +52 -0
- package/claude-plugin/scripts/recommendation-log.js +30 -1
- package/claude-plugin/scripts/recommendation-log.test.js +22 -0
- package/claude-plugin/scripts/user-prompt-context.js +7 -2
- package/claude-plugin/scripts/user-prompt-context.test.js +27 -0
- package/package.json +6 -6
|
@@ -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
|
-
|
|
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
|
-
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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, '
|
|
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))
|
|
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)
|
|
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, {
|
|
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(
|
|
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.
|
|
3
|
+
"version": "0.57.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.57.0",
|
|
39
|
+
"@sdsrs/code-graph-linux-arm64": "0.57.0",
|
|
40
|
+
"@sdsrs/code-graph-darwin-x64": "0.57.0",
|
|
41
|
+
"@sdsrs/code-graph-darwin-arm64": "0.57.0",
|
|
42
|
+
"@sdsrs/code-graph-win32-x64": "0.57.0"
|
|
43
43
|
}
|
|
44
44
|
}
|