@sdsrs/code-graph 0.49.0 → 0.51.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 +49 -7
- package/claude-plugin/scripts/auto-update.test.js +93 -1
- package/claude-plugin/scripts/cg-answer.js +10 -1
- package/claude-plugin/scripts/cg-answer.test.js +10 -1
- package/claude-plugin/scripts/doctor.js +24 -45
- package/claude-plugin/scripts/doctor.test.js +13 -0
- package/claude-plugin/scripts/lifecycle.js +88 -0
- package/claude-plugin/scripts/lifecycle.test.js +40 -1
- package/claude-plugin/scripts/pre-grep-guide.js +60 -11
- package/claude-plugin/scripts/pre-grep-guide.test.js +142 -0
- package/claude-plugin/scripts/session-init.js +38 -14
- package/claude-plugin/templates/plugin_code_graph_mcp.md +3 -0
- package/package.json +6 -6
|
@@ -6,6 +6,7 @@ const https = require('https');
|
|
|
6
6
|
const path = require('path');
|
|
7
7
|
const os = require('os');
|
|
8
8
|
const { CACHE_DIR, PLUGIN_ID, MARKETPLACE_NAME, readManifest, readJson, writeJsonAtomic, installedPluginsPath, pluginsCacheDir } = require('./lifecycle');
|
|
9
|
+
const { claudeHome } = require('./claude-config');
|
|
9
10
|
const { clearCache: clearBinaryCache } = require('./find-binary');
|
|
10
11
|
const { readBinaryVersion, isDevMode } = require('./version-utils');
|
|
11
12
|
const { cgTmpDir } = require('./tmp-dir');
|
|
@@ -277,9 +278,41 @@ function promoteVerifiedBinary(binaryTmp, binaryDst, expectedVersion) {
|
|
|
277
278
|
}
|
|
278
279
|
}
|
|
279
280
|
|
|
281
|
+
// ── Marketplace clone refresh ──────────────────────────────
|
|
282
|
+
|
|
283
|
+
function marketplaceCloneDir() {
|
|
284
|
+
return path.join(claudeHome(), 'plugins', 'marketplaces', MARKETPLACE_NAME);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Fast-forward the Claude Code marketplace clone after a plugin update.
|
|
289
|
+
*
|
|
290
|
+
* Auto-update writes the plugin cache + installed_plugins.json directly and
|
|
291
|
+
* never touched the marketplace clone, so its marketplace.json stayed pinned
|
|
292
|
+
* at the version present when the user last ran a /plugin command (observed
|
|
293
|
+
* live: clone at 0.48.0 four days after 0.49.0 shipped). A stale clone makes
|
|
294
|
+
* the /plugin UI report the old version and lets Claude Code re-install the
|
|
295
|
+
* old plugin files from it. --ff-only + silent failure: a dirty or diverged
|
|
296
|
+
* clone is Claude Code's property — never force anything there.
|
|
297
|
+
*/
|
|
298
|
+
function refreshMarketplaceClone({ dir = marketplaceCloneDir(), exec = execFileSync, timeoutMs = 15000 } = {}) {
|
|
299
|
+
try {
|
|
300
|
+
if (!fs.existsSync(path.join(dir, '.git'))) return false;
|
|
301
|
+
if (!commandExists('git')) return false;
|
|
302
|
+
exec('git', ['-C', dir, 'pull', '--ff-only', '--quiet'], { timeout: timeoutMs, stdio: 'pipe' });
|
|
303
|
+
return true;
|
|
304
|
+
} catch {
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
280
309
|
// ── Download & Install ─────────────────────────────────────
|
|
281
310
|
|
|
282
|
-
async function downloadAndInstall(latest
|
|
311
|
+
async function downloadAndInstall(latest, {
|
|
312
|
+
exec = execFileSync,
|
|
313
|
+
downloadBin = downloadBinary,
|
|
314
|
+
refreshMarketplace = refreshMarketplaceClone,
|
|
315
|
+
} = {}) {
|
|
283
316
|
// Pre-flight: check required CLI tools before attempting any download
|
|
284
317
|
const missingTools = ['curl', 'tar'].filter(cmd => !commandExists(cmd));
|
|
285
318
|
if (missingTools.length > 0) {
|
|
@@ -290,19 +323,20 @@ async function downloadAndInstall(latest) {
|
|
|
290
323
|
const tmpDir = path.join(cgTmpDir(), `update-${Date.now()}`);
|
|
291
324
|
let pluginUpdated = false;
|
|
292
325
|
let binaryUpdated = false;
|
|
326
|
+
let marketplaceRefreshed = false;
|
|
293
327
|
|
|
294
328
|
try {
|
|
295
329
|
fs.mkdirSync(tmpDir, { recursive: true });
|
|
296
330
|
|
|
297
331
|
// ── Step 1: Download and install plugin files from tarball ──
|
|
298
332
|
const tarballPath = path.join(tmpDir, 'release.tar.gz');
|
|
299
|
-
|
|
333
|
+
exec('curl', [
|
|
300
334
|
'-sL', '-o', tarballPath,
|
|
301
335
|
'-H', 'Accept: application/vnd.github+json',
|
|
302
336
|
latest.tarballUrl,
|
|
303
337
|
], { timeout: 30000, stdio: 'pipe' });
|
|
304
338
|
|
|
305
|
-
|
|
339
|
+
exec('tar', [
|
|
306
340
|
'xzf', tarballPath, '-C', tmpDir, '--strip-components=1',
|
|
307
341
|
], { timeout: 15000, stdio: 'pipe' });
|
|
308
342
|
|
|
@@ -344,22 +378,28 @@ async function downloadAndInstall(latest) {
|
|
|
344
378
|
try {
|
|
345
379
|
const newLifecycle = path.join(pluginDst, 'scripts', 'lifecycle.js');
|
|
346
380
|
if (fs.existsSync(newLifecycle)) {
|
|
347
|
-
|
|
381
|
+
exec(process.execPath, [newLifecycle, 'update'], {
|
|
348
382
|
timeout: 5000, stdio: 'pipe',
|
|
349
383
|
});
|
|
350
384
|
}
|
|
351
385
|
} catch { /* not fatal — syncLifecycleConfig will self-heal on next session */ }
|
|
352
386
|
}
|
|
353
387
|
|
|
388
|
+
// ── Step 1.5: Fast-forward the marketplace clone so /plugin UI and any
|
|
389
|
+
// Claude-Code-side reinstall see the version we just installed.
|
|
390
|
+
if (pluginUpdated) {
|
|
391
|
+
marketplaceRefreshed = refreshMarketplace();
|
|
392
|
+
}
|
|
393
|
+
|
|
354
394
|
// ── Step 2: Download platform binary directly from GitHub release ──
|
|
355
|
-
if (await
|
|
395
|
+
if (await downloadBin(latest)) {
|
|
356
396
|
binaryUpdated = true;
|
|
357
397
|
}
|
|
358
398
|
|
|
359
|
-
return { pluginUpdated, binaryUpdated };
|
|
399
|
+
return { pluginUpdated, binaryUpdated, marketplaceRefreshed };
|
|
360
400
|
} catch (e) {
|
|
361
401
|
console.error(`[code-graph] Plugin download/extract failed: ${e.message}`);
|
|
362
|
-
return { pluginUpdated: false, binaryUpdated: false };
|
|
402
|
+
return { pluginUpdated: false, binaryUpdated: false, marketplaceRefreshed };
|
|
363
403
|
} finally {
|
|
364
404
|
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ok */ }
|
|
365
405
|
}
|
|
@@ -431,6 +471,7 @@ async function checkForUpdate({ installMissing = false } = {}) {
|
|
|
431
471
|
lastUpdate: success ? new Date().toISOString() : state.lastUpdate,
|
|
432
472
|
rateLimited: false,
|
|
433
473
|
binaryUpdated: result.binaryUpdated,
|
|
474
|
+
marketplaceRefreshed: result.marketplaceRefreshed,
|
|
434
475
|
};
|
|
435
476
|
saveState(newState);
|
|
436
477
|
|
|
@@ -474,6 +515,7 @@ module.exports = {
|
|
|
474
515
|
requestJson, parseLatestRelease, fetchLatestRelease,
|
|
475
516
|
downloadBinary, cachedBinaryPath, cachedBinaryNeedsUpdate, cachedBinaryStaleVsState,
|
|
476
517
|
selfHealStaleBinary,
|
|
518
|
+
downloadAndInstall, refreshMarketplaceClone, marketplaceCloneDir,
|
|
477
519
|
};
|
|
478
520
|
|
|
479
521
|
// CLI: node auto-update.js [check|status] [--silent] [--install-missing]
|
|
@@ -247,4 +247,96 @@ test('fetchLatestRelease parses JSON without relying on global fetch', async ()
|
|
|
247
247
|
|
|
248
248
|
assert.equal(latest.version, '2.0.0');
|
|
249
249
|
assert.equal(latest.tarballUrl, 'https://example.com/release.tgz');
|
|
250
|
-
});
|
|
250
|
+
});
|
|
251
|
+
// ── refreshMarketplaceClone (v0.49.1 marketplace-staleness fix) ────────────
|
|
252
|
+
|
|
253
|
+
const { execFileSync: execGit } = require('child_process');
|
|
254
|
+
const { refreshMarketplaceClone, downloadAndInstall } = require('./auto-update');
|
|
255
|
+
|
|
256
|
+
function git(cwd, ...args) {
|
|
257
|
+
return execGit('git', ['-C', cwd, '-c', 'user.email=t@t', '-c', 'user.name=t', ...args],
|
|
258
|
+
{ stdio: 'pipe', encoding: 'utf8' });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
test('refreshMarketplaceClone fast-forwards a stale clone', (t) => {
|
|
262
|
+
const root = mkDir(t, 'code-graph-mp-');
|
|
263
|
+
const remote = path.join(root, 'remote');
|
|
264
|
+
const clone = path.join(root, 'clone');
|
|
265
|
+
|
|
266
|
+
fs.mkdirSync(remote);
|
|
267
|
+
git(remote, 'init', '-q', '-b', 'main');
|
|
268
|
+
fs.writeFileSync(path.join(remote, 'marketplace.json'), '{"version":"0.48.0"}');
|
|
269
|
+
git(remote, 'add', '.');
|
|
270
|
+
git(remote, 'commit', '-q', '-m', 'v0.48.0');
|
|
271
|
+
execGit('git', ['clone', '-q', remote, clone], { stdio: 'pipe' });
|
|
272
|
+
|
|
273
|
+
// Remote advances (a release bumped marketplace.json) — clone is now stale.
|
|
274
|
+
fs.writeFileSync(path.join(remote, 'marketplace.json'), '{"version":"0.49.0"}');
|
|
275
|
+
git(remote, 'commit', '-q', '-am', 'v0.49.0');
|
|
276
|
+
|
|
277
|
+
assert.equal(refreshMarketplaceClone({ dir: clone }), true);
|
|
278
|
+
assert.match(fs.readFileSync(path.join(clone, 'marketplace.json'), 'utf8'), /0\.49\.0/);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test('refreshMarketplaceClone is a safe no-op on non-git dirs and pull failures', (t) => {
|
|
282
|
+
const root = mkDir(t, 'code-graph-mp-');
|
|
283
|
+
// Not a git repo → false, no throw.
|
|
284
|
+
assert.equal(refreshMarketplaceClone({ dir: root }), false);
|
|
285
|
+
// Missing dir → false, no throw.
|
|
286
|
+
assert.equal(refreshMarketplaceClone({ dir: path.join(root, 'nope') }), false);
|
|
287
|
+
// exec throws (diverged / dirty clone) → false, no throw.
|
|
288
|
+
const fakeGitDir = path.join(root, 'repo');
|
|
289
|
+
fs.mkdirSync(path.join(fakeGitDir, '.git'), { recursive: true });
|
|
290
|
+
assert.equal(refreshMarketplaceClone({
|
|
291
|
+
dir: fakeGitDir,
|
|
292
|
+
exec: () => { throw new Error('not a fast-forward'); },
|
|
293
|
+
}), false);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test('downloadAndInstall wires the marketplace refresh + binary download (orchestration glue)', async (t) => {
|
|
297
|
+
// In-process with all side-effectful deps injected would still write the
|
|
298
|
+
// manifest into the REAL ~/.cache (CACHE_DIR is bound at module load), so
|
|
299
|
+
// run in a subprocess with a sandboxed HOME — same pattern as install-e2e.
|
|
300
|
+
const sandboxHome = mkDir(t, 'code-graph-dai-');
|
|
301
|
+
const script = `
|
|
302
|
+
const fs = require('fs');
|
|
303
|
+
const path = require('path');
|
|
304
|
+
const { downloadAndInstall } = require(${JSON.stringify(path.join(__dirname, 'auto-update.js'))});
|
|
305
|
+
const latest = { version: '9.9.9', tarballUrl: 'https://example/tar', binaryUrl: null };
|
|
306
|
+
const calls = [];
|
|
307
|
+
const exec = (cmd, args) => {
|
|
308
|
+
calls.push(cmd);
|
|
309
|
+
if (cmd === 'tar') {
|
|
310
|
+
// Simulate extraction: produce claude-plugin/ with a matching version.
|
|
311
|
+
const tmpDir = args[args.indexOf('-C') + 1];
|
|
312
|
+
const mDir = path.join(tmpDir, 'claude-plugin', '.claude-plugin');
|
|
313
|
+
fs.mkdirSync(mDir, { recursive: true });
|
|
314
|
+
fs.writeFileSync(path.join(mDir, 'plugin.json'), JSON.stringify({ version: '9.9.9' }));
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
(async () => {
|
|
318
|
+
let refreshed = 0;
|
|
319
|
+
let binDownloads = 0;
|
|
320
|
+
const result = await downloadAndInstall(latest, {
|
|
321
|
+
exec,
|
|
322
|
+
refreshMarketplace: () => { refreshed++; return true; },
|
|
323
|
+
downloadBin: async () => { binDownloads++; return true; },
|
|
324
|
+
});
|
|
325
|
+
console.log(JSON.stringify({ result, refreshed, binDownloads, calls }));
|
|
326
|
+
})();
|
|
327
|
+
`;
|
|
328
|
+
const out = execGit(process.execPath, ['-e', script], {
|
|
329
|
+
env: { ...process.env, HOME: sandboxHome },
|
|
330
|
+
encoding: 'utf8',
|
|
331
|
+
});
|
|
332
|
+
const { result, refreshed, binDownloads } = JSON.parse(out.trim().split('\n').pop());
|
|
333
|
+
assert.equal(result.pluginUpdated, true, 'plugin files must install from the extracted tarball');
|
|
334
|
+
assert.equal(refreshed, 1, 'marketplace refresh must run exactly once after a plugin update');
|
|
335
|
+
assert.equal(result.marketplaceRefreshed, true);
|
|
336
|
+
assert.equal(binDownloads, 1, 'binary download must run');
|
|
337
|
+
assert.equal(result.binaryUpdated, true);
|
|
338
|
+
// Plugin landed in the sandboxed cache, not the real one.
|
|
339
|
+
const dst = path.join(sandboxHome, '.claude', 'plugins', 'cache',
|
|
340
|
+
'code-graph-mcp', 'code-graph-mcp', '9.9.9', '.claude-plugin', 'plugin.json');
|
|
341
|
+
assert.equal(fs.existsSync(dst), true, 'plugin copied into sandbox plugins cache');
|
|
342
|
+
});
|
|
@@ -114,7 +114,16 @@ function runGrepAnswer(opts = {}) {
|
|
|
114
114
|
// The CLI skips its recommendations.jsonl `use` record when this is set.
|
|
115
115
|
env: { ...process.env, CODE_GRAPH_INTERNAL: '1' },
|
|
116
116
|
});
|
|
117
|
-
if (res.error || res.signal
|
|
117
|
+
if (res.error || res.signal) {
|
|
118
|
+
return { status: 'unavailable' };
|
|
119
|
+
}
|
|
120
|
+
// v0.50 grep-parity exit codes: 0 = matched, 1 = no match, 2 = error.
|
|
121
|
+
// Older binaries exit 0 on no-match with the NO_MATCH_PREFIX on stderr
|
|
122
|
+
// (stdout empty) — both shapes resolve to 'no-hits' below.
|
|
123
|
+
if (res.status === 1) {
|
|
124
|
+
return { status: 'no-hits' };
|
|
125
|
+
}
|
|
126
|
+
if (res.status !== 0) {
|
|
118
127
|
return { status: 'unavailable' };
|
|
119
128
|
}
|
|
120
129
|
const out = (res.stdout || '').trim();
|
|
@@ -21,6 +21,9 @@ if (pattern === 'HangForever') { setTimeout(() => {}, 60000); }
|
|
|
21
21
|
else if (pattern === 'ExplodePlease') { process.exit(3); }
|
|
22
22
|
else if (pattern === 'NothingHere') {
|
|
23
23
|
process.stdout.write('[code-graph] No matches for: NothingHere\\n');
|
|
24
|
+
} else if (pattern === 'NothingHereExit1') {
|
|
25
|
+
// v0.50 grep-parity binary: no match → empty stdout + exit 1
|
|
26
|
+
process.exit(1);
|
|
24
27
|
} else {
|
|
25
28
|
process.stdout.write(
|
|
26
29
|
'src/storage/db.rs:42 fn ' + pattern + '() {\\n' +
|
|
@@ -81,7 +84,13 @@ test('runGrepAnswer: CLI "[code-graph] No matches" → status no-hits', () => {
|
|
|
81
84
|
assert.equal(r.status, 'no-hits');
|
|
82
85
|
});
|
|
83
86
|
|
|
84
|
-
test('runGrepAnswer:
|
|
87
|
+
test('runGrepAnswer: exit 1 (v0.50 grep-parity no-match) → status no-hits', () => {
|
|
88
|
+
const r = runGrepAnswer({ cwd: stubDir, pattern: 'NothingHereExit1', binary: stubBinary() });
|
|
89
|
+
assert.equal(r.status, 'no-hits',
|
|
90
|
+
'grep-parity exit 1 means no match, not a failed binary');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('runGrepAnswer: exit >1 → unavailable', () => {
|
|
85
94
|
const r = runGrepAnswer({ cwd: stubDir, pattern: 'ExplodePlease', binary: stubBinary() });
|
|
86
95
|
assert.equal(r.status, 'unavailable');
|
|
87
96
|
});
|
|
@@ -7,7 +7,7 @@ const os = require('os');
|
|
|
7
7
|
const { readBinaryVersion, isDevMode, getNewestMtime } = require('./version-utils');
|
|
8
8
|
const {
|
|
9
9
|
getPluginVersion, readJson, healthCheck, CACHE_DIR,
|
|
10
|
-
|
|
10
|
+
settingsPath, surveyHookCoverage,
|
|
11
11
|
} = require('./lifecycle');
|
|
12
12
|
const { findBinary, clearCache: clearBinaryCache } = require('./find-binary');
|
|
13
13
|
|
|
@@ -234,49 +234,6 @@ function runDiagnostics() {
|
|
|
234
234
|
return results;
|
|
235
235
|
}
|
|
236
236
|
|
|
237
|
-
// Inventory of (event, matcher) tuples we expect to find in settings.json after
|
|
238
|
-
// install. Used by doctor to detect missing entries.
|
|
239
|
-
function surveyHookCoverage(settings) {
|
|
240
|
-
const desired = buildSettingsHookEntries();
|
|
241
|
-
const expected = [];
|
|
242
|
-
const desiredCmd = {}; // key -> command string we would write now
|
|
243
|
-
for (const [event, entries] of Object.entries(desired)) {
|
|
244
|
-
for (const e of entries) {
|
|
245
|
-
const key = `${event}:${e.matcher || '*'}`;
|
|
246
|
-
expected.push(key);
|
|
247
|
-
desiredCmd[key] = e.hooks && e.hooks[0] && e.hooks[0].command;
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
const present = new Set();
|
|
252
|
-
const presentCmd = {}; // key -> command currently registered
|
|
253
|
-
if (settings && settings.hooks) {
|
|
254
|
-
for (const [event, entries] of Object.entries(settings.hooks)) {
|
|
255
|
-
if (!Array.isArray(entries)) continue;
|
|
256
|
-
for (const entry of entries) {
|
|
257
|
-
if (isOurHookEntry(entry)) {
|
|
258
|
-
const key = `${event}:${entry.matcher || '*'}`;
|
|
259
|
-
present.add(key);
|
|
260
|
-
if (entry.hooks && entry.hooks[0] && entry.hooks[0].command) {
|
|
261
|
-
presentCmd[key] = entry.hooks[0].command;
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
const missing = expected.filter(k => !present.has(k));
|
|
269
|
-
// Stale = present but the registered command no longer matches what we'd write
|
|
270
|
-
// now (points at an old plugin-cache version dir / moved path). A stale path can
|
|
271
|
-
// run pre-recordRecommendation hook code, so the hook fires but the conversion
|
|
272
|
-
// metric stays dark — invisible to a present/absent check. This is the
|
|
273
|
-
// 0.45.1-registered-while-0.45.4-active case the RCA surfaced.
|
|
274
|
-
const stale = expected.filter(k =>
|
|
275
|
-
present.has(k) && desiredCmd[k] && presentCmd[k] && presentCmd[k] !== desiredCmd[k]
|
|
276
|
-
);
|
|
277
|
-
return { expected, present: [...present], missing, stale };
|
|
278
|
-
}
|
|
279
|
-
|
|
280
237
|
// ── Report Formatting ─────────────────────────────────────
|
|
281
238
|
|
|
282
239
|
const STATUS_ICONS = { ok: '\u2705', warn: '\u26a0\ufe0f', error: '\u274c', skip: '\u2796' };
|
|
@@ -306,6 +263,26 @@ function formatReport(results) {
|
|
|
306
263
|
|
|
307
264
|
// ── Repair Actions ────────────────────────────────────────
|
|
308
265
|
|
|
266
|
+
/**
|
|
267
|
+
* v0.50.0: settings-writing repairs get the same stale-relic guard as
|
|
268
|
+
* session-init. A doctor launched from an old plugin-cache version dir would
|
|
269
|
+
* otherwise install() and re-anchor manifest + settings.json hook paths to the
|
|
270
|
+
* relic — the exact downgrade war the guard exists for, just user-triggered.
|
|
271
|
+
* Returns true (and prints redirection) when this copy must NOT write config.
|
|
272
|
+
* `relic` is injectable for tests.
|
|
273
|
+
*/
|
|
274
|
+
function relicRepairGuard({ log = console.log, relic = undefined } = {}) {
|
|
275
|
+
const { isStaleRelicContext, activeInstallPath } = require('./lifecycle');
|
|
276
|
+
const isRelic = relic !== undefined ? relic : isStaleRelicContext();
|
|
277
|
+
if (!isRelic) return false;
|
|
278
|
+
const active = activeInstallPath();
|
|
279
|
+
log(' ⚠ This doctor copy is not the active install (installed_plugins.json points elsewhere) — skipping settings repair.');
|
|
280
|
+
if (active) {
|
|
281
|
+
log(` Run the active copy instead: node "${path.join(active, 'scripts', 'doctor.js')}"`);
|
|
282
|
+
}
|
|
283
|
+
return true;
|
|
284
|
+
}
|
|
285
|
+
|
|
309
286
|
function runRepairs(results) {
|
|
310
287
|
const fixable = results.filter(r => r.fixId);
|
|
311
288
|
if (fixable.length === 0) return 0;
|
|
@@ -425,6 +402,7 @@ function runRepairs(results) {
|
|
|
425
402
|
|
|
426
403
|
case 'hooks-invalid': {
|
|
427
404
|
console.log('\n Repairing hooks...');
|
|
405
|
+
if (relicRepairGuard()) break;
|
|
428
406
|
const { install } = require('./lifecycle');
|
|
429
407
|
install();
|
|
430
408
|
console.log(' \u2705 Hooks repaired \u2014 restart Claude Code to apply');
|
|
@@ -434,6 +412,7 @@ function runRepairs(results) {
|
|
|
434
412
|
|
|
435
413
|
case 'missing-hooks-in-settings': {
|
|
436
414
|
console.log('\n Registering code-graph hooks in settings.json...');
|
|
415
|
+
if (relicRepairGuard()) break;
|
|
437
416
|
const { install } = require('./lifecycle');
|
|
438
417
|
const r = install();
|
|
439
418
|
if (r.hooksRegistered) {
|
|
@@ -474,7 +453,7 @@ function runDoctor(opts = {}) {
|
|
|
474
453
|
return { results, issueCount: issues.length };
|
|
475
454
|
}
|
|
476
455
|
|
|
477
|
-
module.exports = { runDiagnostics, formatReport, runRepairs, runDoctor, surveyHookCoverage };
|
|
456
|
+
module.exports = { runDiagnostics, formatReport, runRepairs, runDoctor, surveyHookCoverage, relicRepairGuard };
|
|
478
457
|
|
|
479
458
|
if (require.main === module) {
|
|
480
459
|
const args = process.argv.slice(2);
|
|
@@ -80,3 +80,16 @@ test('surveyHookCoverage flags missing entries when settings empty', () => {
|
|
|
80
80
|
assert.ok(cov.missing.length === cov.expected.length, 'all expected entries missing');
|
|
81
81
|
assert.equal(cov.stale.length, 0, 'nothing present to be stale');
|
|
82
82
|
});
|
|
83
|
+
|
|
84
|
+
// ── relicRepairGuard (v0.50.0 — doctor twin of the session-init relic guard) ──
|
|
85
|
+
|
|
86
|
+
test('relicRepairGuard blocks settings repair from a relic copy and redirects', () => {
|
|
87
|
+
const { relicRepairGuard } = require('./doctor');
|
|
88
|
+
const lines = [];
|
|
89
|
+
// Relic context → guard fires, prints the redirect, returns true (skip install).
|
|
90
|
+
assert.equal(relicRepairGuard({ relic: true, log: (s) => lines.push(s) }), true);
|
|
91
|
+
assert.ok(lines.some(l => l.includes('not the active install')),
|
|
92
|
+
`guard must explain why repair is skipped, got: ${lines.join(' | ')}`);
|
|
93
|
+
// Active (or dev/npm) context → repair proceeds.
|
|
94
|
+
assert.equal(relicRepairGuard({ relic: false, log: () => {} }), false);
|
|
95
|
+
});
|
|
@@ -75,6 +75,48 @@ function hasInstalledPluginRecord() {
|
|
|
75
75
|
return !!(installed && installed.plugins && Array.isArray(installed.plugins[PLUGIN_ID]) && installed.plugins[PLUGIN_ID].length > 0);
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/** The installPath Claude Code currently considers active (installed_plugins.json), or null. */
|
|
79
|
+
function activeInstallPath() {
|
|
80
|
+
const installed = readJson(installedPluginsPath());
|
|
81
|
+
const recs = installed && installed.plugins && installed.plugins[PLUGIN_ID];
|
|
82
|
+
if (!Array.isArray(recs) || !recs[0] || typeof recs[0].installPath !== 'string') return null;
|
|
83
|
+
return recs[0].installPath;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Stale-relic detection: is THIS script running from an old plugin-cache
|
|
88
|
+
* version dir while installed_plugins.json points at a different (active)
|
|
89
|
+
* install that exists on disk?
|
|
90
|
+
*
|
|
91
|
+
* Why: a still-running Claude Code process keeps firing SessionStart from the
|
|
92
|
+
* install path it loaded at startup. After auto-update installs vN+1 and
|
|
93
|
+
* re-anchors manifest + settings.json, the next SessionStart in that old
|
|
94
|
+
* process runs the vN scripts, whose syncLifecycleConfig sees
|
|
95
|
+
* `manifest.version !== currentVersion` and — direction-blind — calls
|
|
96
|
+
* update(), dragging manifest and every settings.json hook path back to the
|
|
97
|
+
* vN dir. The two versions then ping-pong (observed live 2026-06-12: manifest
|
|
98
|
+
* 0.49.0 → rewritten 0.48.0 fifteen minutes after a successful update).
|
|
99
|
+
*
|
|
100
|
+
* The authority is installed_plugins.json, not version direction: a deliberate
|
|
101
|
+
* downgrade via /plugin lands installPath == the old dir, so the old scripts
|
|
102
|
+
* keep full self-heal rights. Dev checkouts and npm installs are exempt
|
|
103
|
+
* (pluginRoot not under the plugins cache).
|
|
104
|
+
*/
|
|
105
|
+
function isStaleRelicContext({
|
|
106
|
+
pluginRoot = PLUGIN_ROOT,
|
|
107
|
+
cacheRoot = pluginsCacheDir(),
|
|
108
|
+
activePath = activeInstallPath(),
|
|
109
|
+
existsSync = fs.existsSync,
|
|
110
|
+
} = {}) {
|
|
111
|
+
if (!activePath) return false;
|
|
112
|
+
const root = path.resolve(pluginRoot);
|
|
113
|
+
const cache = path.resolve(cacheRoot);
|
|
114
|
+
if (root !== cache && !root.startsWith(cache + path.sep)) return false;
|
|
115
|
+
const active = path.resolve(activePath);
|
|
116
|
+
if (active === root) return false;
|
|
117
|
+
return existsSync(path.join(active, 'scripts', 'lifecycle.js'));
|
|
118
|
+
}
|
|
119
|
+
|
|
78
120
|
function isOurComposite(settings) {
|
|
79
121
|
return settings.statusLine &&
|
|
80
122
|
settings.statusLine.command &&
|
|
@@ -377,6 +419,50 @@ function registerHooksToSettings(settings) {
|
|
|
377
419
|
return before !== JSON.stringify(settings.hooks);
|
|
378
420
|
}
|
|
379
421
|
|
|
422
|
+
// Inventory of (event, matcher) tuples we expect to find in settings.json after
|
|
423
|
+
// install. Consumed by doctor (report + fix) and session-init (self-heal):
|
|
424
|
+
// `missing` = entry absent; `stale` = present but the registered command no
|
|
425
|
+
// longer matches what we'd write now (points at an old plugin-cache version
|
|
426
|
+
// dir / moved path). A stale path can run pre-recordRecommendation hook code,
|
|
427
|
+
// so the hook fires but the conversion metric stays dark — invisible to a
|
|
428
|
+
// present/absent check. This is the 0.45.1-registered-while-0.45.4-active
|
|
429
|
+
// case the RCA surfaced.
|
|
430
|
+
function surveyHookCoverage(settings) {
|
|
431
|
+
const desired = buildSettingsHookEntries();
|
|
432
|
+
const expected = [];
|
|
433
|
+
const desiredCmd = {}; // key -> command string we would write now
|
|
434
|
+
for (const [event, entries] of Object.entries(desired)) {
|
|
435
|
+
for (const e of entries) {
|
|
436
|
+
const key = `${event}:${e.matcher || '*'}`;
|
|
437
|
+
expected.push(key);
|
|
438
|
+
desiredCmd[key] = e.hooks && e.hooks[0] && e.hooks[0].command;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const present = new Set();
|
|
443
|
+
const presentCmd = {}; // key -> command currently registered
|
|
444
|
+
if (settings && settings.hooks) {
|
|
445
|
+
for (const [event, entries] of Object.entries(settings.hooks)) {
|
|
446
|
+
if (!Array.isArray(entries)) continue;
|
|
447
|
+
for (const entry of entries) {
|
|
448
|
+
if (isOurHookEntry(entry)) {
|
|
449
|
+
const key = `${event}:${entry.matcher || '*'}`;
|
|
450
|
+
present.add(key);
|
|
451
|
+
if (entry.hooks && entry.hooks[0] && entry.hooks[0].command) {
|
|
452
|
+
presentCmd[key] = entry.hooks[0].command;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const missing = expected.filter(k => !present.has(k));
|
|
460
|
+
const stale = expected.filter(k =>
|
|
461
|
+
present.has(k) && desiredCmd[k] && presentCmd[k] && presentCmd[k] !== desiredCmd[k]
|
|
462
|
+
);
|
|
463
|
+
return { expected, present: [...present], missing, stale };
|
|
464
|
+
}
|
|
465
|
+
|
|
380
466
|
// --- Install (idempotent) ---
|
|
381
467
|
|
|
382
468
|
function install() {
|
|
@@ -673,6 +759,8 @@ module.exports = {
|
|
|
673
759
|
getPluginVersion, cleanupOldCacheVersions,
|
|
674
760
|
removeHooksFromSettings, isOurHookEntry,
|
|
675
761
|
registerHooksToSettings, buildSettingsHookEntries, // v0.32.0
|
|
762
|
+
surveyHookCoverage, compositeCommand, // v0.49.1 — version-aware self-heal
|
|
763
|
+
activeInstallPath, isStaleRelicContext, // v0.49.1 — stale-relic downgrade guard
|
|
676
764
|
SETTINGS_HOOK_DESC, OUR_HOOK_SCRIPTS, OUR_DESCRIPTIONS, // v0.32.0 — for tests
|
|
677
765
|
PLUGIN_ROOT, // v0.32.1 — for tests / consumers
|
|
678
766
|
registerStatuslineProvider, unregisterStatuslineProvider,
|
|
@@ -660,4 +660,43 @@ test('scanForBrokenPaths is exported and returns the issue structure', (t) => {
|
|
|
660
660
|
assert.ok(Array.isArray(issues));
|
|
661
661
|
assert.ok(issues.some(i => i.type === 'hook' && i.event === 'PreToolUse' && i.path.includes('/nonexistent/')),
|
|
662
662
|
'scanForBrokenPaths must report the seeded broken hook entry');
|
|
663
|
-
});
|
|
663
|
+
});
|
|
664
|
+
// ── isStaleRelicContext (v0.49.1 downgrade-war guard) ──────────────────────
|
|
665
|
+
|
|
666
|
+
test('isStaleRelicContext: relic in plugins cache defers to a different active install', (t) => {
|
|
667
|
+
const { isStaleRelicContext } = require('./lifecycle');
|
|
668
|
+
const cacheRoot = '/home/u/.claude/plugins/cache';
|
|
669
|
+
const relicRoot = `${cacheRoot}/code-graph-mcp/code-graph-mcp/0.48.0`;
|
|
670
|
+
const activeRoot = `${cacheRoot}/code-graph-mcp/code-graph-mcp/0.49.0`;
|
|
671
|
+
|
|
672
|
+
// The downgrade-war case: running from old cache dir, active points elsewhere.
|
|
673
|
+
assert.equal(isStaleRelicContext({
|
|
674
|
+
pluginRoot: relicRoot, cacheRoot, activePath: activeRoot,
|
|
675
|
+
existsSync: () => true,
|
|
676
|
+
}), true);
|
|
677
|
+
|
|
678
|
+
// Running FROM the active install → full self-heal rights.
|
|
679
|
+
assert.equal(isStaleRelicContext({
|
|
680
|
+
pluginRoot: activeRoot, cacheRoot, activePath: activeRoot,
|
|
681
|
+
existsSync: () => true,
|
|
682
|
+
}), false);
|
|
683
|
+
|
|
684
|
+
// Dev checkout / npm install (pluginRoot outside the plugins cache) → exempt.
|
|
685
|
+
assert.equal(isStaleRelicContext({
|
|
686
|
+
pluginRoot: '/repo/code-graph-mcp/claude-plugin', cacheRoot, activePath: activeRoot,
|
|
687
|
+
existsSync: () => true,
|
|
688
|
+
}), false);
|
|
689
|
+
|
|
690
|
+
// No installed_plugins record → exempt (nothing authoritative to defer to).
|
|
691
|
+
assert.equal(isStaleRelicContext({
|
|
692
|
+
pluginRoot: relicRoot, cacheRoot, activePath: null,
|
|
693
|
+
existsSync: () => true,
|
|
694
|
+
}), false);
|
|
695
|
+
|
|
696
|
+
// Active path recorded but its lifecycle.js is gone (cache wiped) → the
|
|
697
|
+
// relic is the only working copy left; keep self-heal rights.
|
|
698
|
+
assert.equal(isStaleRelicContext({
|
|
699
|
+
pluginRoot: relicRoot, cacheRoot, activePath: activeRoot,
|
|
700
|
+
existsSync: () => false,
|
|
701
|
+
}), false);
|
|
702
|
+
});
|
|
@@ -242,6 +242,44 @@ function extractSedReadTargets(cmd) {
|
|
|
242
242
|
return out;
|
|
243
243
|
}
|
|
244
244
|
|
|
245
|
+
// v0.50 — a compound command (`grep …; sed -n 1,60p f` / `grep … && wc`) is
|
|
246
|
+
// denied WHOLE, but the answer covers only the grep. The 2026-06-13 mem-project
|
|
247
|
+
// deny swallowed a `; sed` read while the copy said "use these results directly
|
|
248
|
+
// instead of re-running" — the tail's intent was silently dropped. Extract the
|
|
249
|
+
// first top-level `;`/`&&` tail (quote-aware) so the deny can flag it for
|
|
250
|
+
// re-issue. `||` tails are skipped: the answer delivered hits, so the on-failure
|
|
251
|
+
// branch would not have run anyway. Pipes/redirects are the same pipeline.
|
|
252
|
+
function extractUnansweredTail(cmd) {
|
|
253
|
+
if (!cmd || typeof cmd !== 'string' || cmd.length > 2000) return null;
|
|
254
|
+
let quote = null;
|
|
255
|
+
for (let i = 0; i < cmd.length; i++) {
|
|
256
|
+
const c = cmd[i];
|
|
257
|
+
if (quote) {
|
|
258
|
+
if (c === quote) quote = null;
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (c === '"' || c === "'") { quote = c; continue; }
|
|
262
|
+
if (c === ';' || (c === '&' && cmd[i + 1] === '&')) {
|
|
263
|
+
const tail = cmd.slice(i + (c === ';' ? 1 : 2)).trim();
|
|
264
|
+
return tail || null;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Shared deny-copy footer for the unanswered compound tail. Budget-conscious:
|
|
271
|
+
// two lines, verbatim command so the model can re-issue without thinking.
|
|
272
|
+
// Wording is path-neutral — it must stay true for BOTH the answered deny
|
|
273
|
+
// ("grep half answered, tail wasn't") and the static fallback (nothing was).
|
|
274
|
+
function appendUnansweredTailNote(lines, tail) {
|
|
275
|
+
if (!tail) return;
|
|
276
|
+
const shown = tail.length > 300 ? tail.slice(0, 300) + '…' : tail;
|
|
277
|
+
lines.push(
|
|
278
|
+
'NOTE: the rest of this compound command (after the grep) did NOT run — re-issue it separately:',
|
|
279
|
+
`$ ${shown}`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
245
283
|
// v0.47.0 — pull the first source-tree path token out of the denied command so
|
|
246
284
|
// the inline answer can scope its search the same way the raw grep would have.
|
|
247
285
|
function extractSearchPath(cmd) {
|
|
@@ -282,7 +320,7 @@ function buildHint() {
|
|
|
282
320
|
// Terse, no banner spam. Single message budget ~600 bytes.
|
|
283
321
|
return [
|
|
284
322
|
'[code-graph] Raw `grep`/`rg` on indexed source — consider AST-aware equivalents:',
|
|
285
|
-
' • code-graph-mcp grep "<pat>"
|
|
323
|
+
' • code-graph-mcp grep "<pat>" [paths...] # grep + containing fn/module per hit (-F literal, -i, -w, -l, -C N)',
|
|
286
324
|
' • code-graph-mcp ast-search "<pat>" --type fn # filter by type/returns/params',
|
|
287
325
|
' • code-graph-mcp callgraph SYMBOL # callers + callees, repo-wide',
|
|
288
326
|
' • code-graph-mcp show SYMBOL # one symbol: signature + source',
|
|
@@ -290,20 +328,22 @@ function buildHint() {
|
|
|
290
328
|
].join('\n');
|
|
291
329
|
}
|
|
292
330
|
|
|
293
|
-
function buildBlockReason() {
|
|
331
|
+
function buildBlockReason(unansweredTail) {
|
|
294
332
|
// Shown to Claude via PreToolUse `decision: block` reason. Must give a
|
|
295
333
|
// concrete alternate command Claude can re-issue without further thinking.
|
|
296
334
|
// v0.49 — NO escape-hatch line anywhere in deny copy: the daagu 2026-06-12
|
|
297
335
|
// night proved even the "THIS command only" scoping reads as a teachable
|
|
298
336
|
// permanent prefix (adopted in 8s, reused 11×, incl. on the exact identifier
|
|
299
337
|
// searches this hook targets). The env opt-out stays documented in README.
|
|
300
|
-
|
|
338
|
+
const lines = [
|
|
301
339
|
'[code-graph] Raw `grep -rn` on indexed source — denied by code-graph hook.',
|
|
302
340
|
'Use the AST-aware equivalent (returns containing fn/module per hit, repo-wide):',
|
|
303
|
-
' code-graph-mcp grep "<pattern>"
|
|
341
|
+
' code-graph-mcp grep "<pattern>" [paths...] # AST context per hit; -F literal, -i, -w, -l, -C N, --max-count 0',
|
|
304
342
|
' code-graph-mcp ast-search "<pattern>" --type fn # filter by node type',
|
|
305
343
|
' code-graph-mcp callgraph SYMBOL # callers + callees',
|
|
306
|
-
]
|
|
344
|
+
];
|
|
345
|
+
appendUnansweredTailNote(lines, unansweredTail);
|
|
346
|
+
return lines.join('\n');
|
|
307
347
|
}
|
|
308
348
|
|
|
309
349
|
// v0.47.0 — deny WITH the answer inline. Hint-only had ~0% transfer and a bare
|
|
@@ -313,7 +353,7 @@ function buildBlockReason() {
|
|
|
313
353
|
// advertising the bypass taught it a permanent prefix within 5 seconds (daagu
|
|
314
354
|
// 2026-06-11: one deny → 14 bypassed greps). The static deny keeps a scoped
|
|
315
355
|
// escape because there we give no answer.
|
|
316
|
-
function buildBlockReasonWithAnswer(pattern, searchPath, answer) {
|
|
356
|
+
function buildBlockReasonWithAnswer(pattern, searchPath, answer, unansweredTail) {
|
|
317
357
|
const cmdShown = `code-graph-mcp grep "${pattern}"${searchPath ? ` ${searchPath}` : ''}`;
|
|
318
358
|
const lines = [
|
|
319
359
|
'[code-graph] Raw `grep` on indexed source — denied; the AST-aware equivalent already ran for you:',
|
|
@@ -326,6 +366,7 @@ function buildBlockReasonWithAnswer(pattern, searchPath, answer) {
|
|
|
326
366
|
lines.push(
|
|
327
367
|
'Each hit shows its containing fn/module — use these results directly instead of re-running the search.',
|
|
328
368
|
);
|
|
369
|
+
appendUnansweredTailNote(lines, unansweredTail);
|
|
329
370
|
return lines.join('\n');
|
|
330
371
|
}
|
|
331
372
|
|
|
@@ -333,7 +374,7 @@ function buildBlockReasonWithAnswer(pattern, searchPath, answer) {
|
|
|
333
374
|
// context flags (-A/-B/-C = "show me the body"); the answer IS the bodies,
|
|
334
375
|
// fetched via `code-graph-mcp show`. answer.text already carries per-symbol
|
|
335
376
|
// `$ code-graph-mcp show <sym>` headers.
|
|
336
|
-
function buildShowDenyReason(answer) {
|
|
377
|
+
function buildShowDenyReason(answer, unansweredTail) {
|
|
337
378
|
const lines = [
|
|
338
379
|
'[code-graph] Raw grep for symbol definitions — denied; here are the definitions from the AST index:',
|
|
339
380
|
answer.text,
|
|
@@ -342,6 +383,7 @@ function buildShowDenyReason(answer) {
|
|
|
342
383
|
lines.push('(truncated — re-run the `code-graph-mcp show <symbol>` command above for full source)');
|
|
343
384
|
}
|
|
344
385
|
lines.push('Use these directly instead of re-running the search.');
|
|
386
|
+
appendUnansweredTailNote(lines, unansweredTail);
|
|
345
387
|
return lines.join('\n');
|
|
346
388
|
}
|
|
347
389
|
|
|
@@ -364,7 +406,7 @@ function translateBreToRg(cmd, pattern) {
|
|
|
364
406
|
// ripgrep) mean 0 hits is NOT proof of absence, so denying here could mislead.
|
|
365
407
|
// Let the raw grep through with an honest one-liner.
|
|
366
408
|
function buildNoHitsFyi(pattern) {
|
|
367
|
-
return `[code-graph] FYI: \`code-graph-mcp grep "${pattern}"\` found no matches — raw grep proceeding
|
|
409
|
+
return `[code-graph] FYI: \`code-graph-mcp grep "${pattern}"\` found no matches — raw grep proceeding. (Regex-metachar patterns: \`code-graph-mcp grep -F\` searches literally.)`;
|
|
368
410
|
}
|
|
369
411
|
|
|
370
412
|
// --- Main execution (only when run directly) ---
|
|
@@ -483,20 +525,26 @@ function runMain() {
|
|
|
483
525
|
// is the documented modern path. Exit 0 — this is a routing decision, not
|
|
484
526
|
// a hook failure (exit 2 would mark the tool call as "hook errored").
|
|
485
527
|
const answered = answer.status === 'hits';
|
|
528
|
+
// v0.50 — flag the unanswered `;`/`&&` tail. Extracted from rawCmd: its
|
|
529
|
+
// paths are valid in the model's shell cwd, where the re-issue will run.
|
|
530
|
+
const unansweredTail = extractUnansweredTail(rawCmd);
|
|
486
531
|
recordRecommendation(root, {
|
|
487
532
|
hook: 'grep', action: 'deny', answered,
|
|
488
533
|
// mode segments which answer type converts (show=bodies, grep=hits).
|
|
489
534
|
...(answered ? { mode: answeredMode } : {}),
|
|
535
|
+
// tail segments compound-command denies — lets the funnel compare
|
|
536
|
+
// re-issue behavior for denies that carried a tail note.
|
|
537
|
+
...(unansweredTail ? { tail: true } : {}),
|
|
490
538
|
});
|
|
491
539
|
process.stdout.write(JSON.stringify({
|
|
492
540
|
hookSpecificOutput: {
|
|
493
541
|
hookEventName: 'PreToolUse',
|
|
494
542
|
permissionDecision: 'deny',
|
|
495
543
|
permissionDecisionReason: !answered
|
|
496
|
-
? buildBlockReason()
|
|
544
|
+
? buildBlockReason(unansweredTail)
|
|
497
545
|
: answeredMode === 'show'
|
|
498
|
-
? buildShowDenyReason(answer)
|
|
499
|
-
: buildBlockReasonWithAnswer(pattern, searchPath, answer),
|
|
546
|
+
? buildShowDenyReason(answer, unansweredTail)
|
|
547
|
+
: buildBlockReasonWithAnswer(pattern, searchPath, answer, unansweredTail),
|
|
500
548
|
},
|
|
501
549
|
}) + '\n');
|
|
502
550
|
return;
|
|
@@ -518,6 +566,7 @@ module.exports = {
|
|
|
518
566
|
translateBreToRg, // v0.49 — BRE→rust-regex dialect bridge
|
|
519
567
|
buildShowDenyReason, // v0.49 — show-mode deny copy
|
|
520
568
|
extractSedReadTargets, // v0.49 — sed-range reads feed the read-fanout state
|
|
569
|
+
extractUnansweredTail, // v0.50 — compound-tail honesty in answered denies
|
|
521
570
|
extractPatterns, // v0.32.1 — exposed for tests
|
|
522
571
|
extractSearchPath, // v0.47.0 — deny-with-answer
|
|
523
572
|
normalizeCommandPaths, // v0.47.1 — abs-path matcher fix
|
|
@@ -9,6 +9,7 @@ const {
|
|
|
9
9
|
translateBreToRg,
|
|
10
10
|
buildShowDenyReason,
|
|
11
11
|
extractSedReadTargets,
|
|
12
|
+
extractUnansweredTail,
|
|
12
13
|
extractPatterns,
|
|
13
14
|
extractSearchPath,
|
|
14
15
|
normalizeCommandPaths,
|
|
@@ -741,6 +742,78 @@ test('buildBlockReasonWithAnswer: truncated flag adds marker', () => {
|
|
|
741
742
|
assert.match(reason, /truncated/);
|
|
742
743
|
});
|
|
743
744
|
|
|
745
|
+
// ── v0.50 compound-command tail: deny answers the grep, NOT the rest ─
|
|
746
|
+
|
|
747
|
+
test('extractUnansweredTail: `; sed` tail after piped grep (2026-06-13 real deny shape)', () => {
|
|
748
|
+
assert.equal(
|
|
749
|
+
extractUnansweredTail(
|
|
750
|
+
'grep -n "mem_update\\|registerTool" tests/server.test.mjs | head -20; sed -n \'1,60p\' tests/server.test.mjs'),
|
|
751
|
+
"sed -n '1,60p' tests/server.test.mjs");
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
test('extractUnansweredTail: && tail is unanswered (would have run on grep success)', () => {
|
|
755
|
+
assert.equal(
|
|
756
|
+
extractUnansweredTail('grep -rn "fts5_search" src/ && wc -l src/storage/db.rs'),
|
|
757
|
+
'wc -l src/storage/db.rs');
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
test('extractUnansweredTail: quoted separators are pattern text, not a tail', () => {
|
|
761
|
+
assert.equal(extractUnansweredTail('grep -rn "a;b" src/'), null);
|
|
762
|
+
assert.equal(extractUnansweredTail("grep -rn 'a && b' src/"), null);
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
test('extractUnansweredTail: pipes and redirects are the same pipeline, not a tail', () => {
|
|
766
|
+
assert.equal(extractUnansweredTail('grep -rn "Foo" src/ 2>&1 | head -10'), null);
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
test('extractUnansweredTail: || branch would NOT have run on hits — no tail', () => {
|
|
770
|
+
assert.equal(extractUnansweredTail('grep -rn "Foo" src/ || echo none'), null);
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
test('extractUnansweredTail: trailing separator with nothing after → no tail', () => {
|
|
774
|
+
assert.equal(extractUnansweredTail('grep -rn "Foo" src/;'), null);
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
test('buildBlockReasonWithAnswer: compound tail → note says the rest did NOT run', () => {
|
|
778
|
+
const reason = buildBlockReasonWithAnswer('fts5_search', 'src/', {
|
|
779
|
+
status: 'hits', text: 'hit', truncated: false,
|
|
780
|
+
}, "sed -n '1,60p' tests/server.test.mjs");
|
|
781
|
+
assert.match(reason, /did NOT run/);
|
|
782
|
+
assert.match(reason, /sed -n '1,60p' tests\/server\.test\.mjs/);
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
test('buildBlockReasonWithAnswer: no tail → no compound note', () => {
|
|
786
|
+
const reason = buildBlockReasonWithAnswer('fts5_search', 'src/', {
|
|
787
|
+
status: 'hits', text: 'hit', truncated: false,
|
|
788
|
+
});
|
|
789
|
+
assert.doesNotMatch(reason, /did NOT run/);
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
test('buildShowDenyReason: compound tail → note says the rest did NOT run', () => {
|
|
793
|
+
const reason = buildShowDenyReason(
|
|
794
|
+
{ status: 'hits', text: 'fn body', truncated: false },
|
|
795
|
+
'cargo test -q');
|
|
796
|
+
assert.match(reason, /did NOT run/);
|
|
797
|
+
assert.match(reason, /cargo test -q/);
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
test('buildShowDenyReason: no tail → no compound note', () => {
|
|
801
|
+
const reason = buildShowDenyReason({ status: 'hits', text: 'fn body', truncated: false });
|
|
802
|
+
assert.doesNotMatch(reason, /did NOT run/);
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
test('buildBlockReason: compound tail → static deny also flags the unanswered tail', () => {
|
|
806
|
+
const reason = buildBlockReason("sed -n '1,60p' tests/server.test.mjs");
|
|
807
|
+
assert.match(reason, /did NOT run/);
|
|
808
|
+
assert.match(reason, /sed -n '1,60p' tests\/server\.test\.mjs/);
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
test('buildBlockReason: no tail → unchanged static deny', () => {
|
|
812
|
+
const reason = buildBlockReason();
|
|
813
|
+
assert.match(reason, /denied by code-graph hook/);
|
|
814
|
+
assert.doesNotMatch(reason, /did NOT run/);
|
|
815
|
+
});
|
|
816
|
+
|
|
744
817
|
test('buildNoHitsFyi: names the pattern and says raw grep proceeds', () => {
|
|
745
818
|
const fyi = buildNoHitsFyi('GhostSymbol');
|
|
746
819
|
assert.match(fyi, /GhostSymbol/);
|
|
@@ -903,6 +976,75 @@ test('e2e: CODE_GRAPH_NO_ANSWER_IN_DENY=1 → static deny even when stub would h
|
|
|
903
976
|
}
|
|
904
977
|
});
|
|
905
978
|
|
|
979
|
+
test('e2e: compound `grep …; sed` → deny answers grep AND flags the unanswered sed tail', () => {
|
|
980
|
+
const uniq = `StubTail${Date.now()}`;
|
|
981
|
+
const fixture = e2eFixture(
|
|
982
|
+
`process.stdout.write('src/foo.rs:7 fn ' + process.argv[3] + '()\\n');`);
|
|
983
|
+
const cmd = `grep -n "${uniq}" src/foo.rs | head -20; sed -n '100,160p' src/foo.rs`;
|
|
984
|
+
try {
|
|
985
|
+
fsE2e.mkdirSync(pathE2e.join(fixture.dir, 'src'), { recursive: true });
|
|
986
|
+
fsE2e.writeFileSync(pathE2e.join(fixture.dir, 'src', 'foo.rs'), 'fn x() {}\n');
|
|
987
|
+
const res = runHook(cmd, fixture);
|
|
988
|
+
assert.equal(res.status, 0);
|
|
989
|
+
const out = JSON.parse(res.stdout);
|
|
990
|
+
assert.equal(out.hookSpecificOutput.permissionDecision, 'deny');
|
|
991
|
+
const reason = out.hookSpecificOutput.permissionDecisionReason;
|
|
992
|
+
assert.match(reason, /src\/foo\.rs:7/); // grep half answered
|
|
993
|
+
assert.match(reason, /did NOT run/); // tail flagged honestly
|
|
994
|
+
assert.match(reason, /sed -n '100,160p' src\/foo\.rs/); // verbatim re-issue line
|
|
995
|
+
// funnel: the deny record marks that a tail note was carried
|
|
996
|
+
const recs = fsE2e.readFileSync(
|
|
997
|
+
pathE2e.join(fixture.dir, '.code-graph', 'recommendations.jsonl'), 'utf8');
|
|
998
|
+
const rec = JSON.parse(recs.trim().split('\n').pop());
|
|
999
|
+
assert.equal(rec.action, 'deny');
|
|
1000
|
+
assert.equal(rec.tail, true);
|
|
1001
|
+
} finally {
|
|
1002
|
+
cleanupFixture(fixture, cmd);
|
|
1003
|
+
}
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
test('e2e: compound cmd + answer failure → STATIC deny still flags tail + records tail:true', () => {
|
|
1007
|
+
const uniq = `StubTailBoom${Date.now()}`;
|
|
1008
|
+
const fixture = e2eFixture(`process.exit(3);`);
|
|
1009
|
+
const cmd = `grep -n "${uniq}" src/foo.rs && cargo test -q`;
|
|
1010
|
+
try {
|
|
1011
|
+
fsE2e.mkdirSync(pathE2e.join(fixture.dir, 'src'), { recursive: true });
|
|
1012
|
+
fsE2e.writeFileSync(pathE2e.join(fixture.dir, 'src', 'foo.rs'), 'fn x() {}\n');
|
|
1013
|
+
const res = runHook(cmd, fixture);
|
|
1014
|
+
assert.equal(res.status, 0);
|
|
1015
|
+
const out = JSON.parse(res.stdout);
|
|
1016
|
+
assert.equal(out.hookSpecificOutput.permissionDecision, 'deny');
|
|
1017
|
+
const reason = out.hookSpecificOutput.permissionDecisionReason;
|
|
1018
|
+
assert.match(reason, /denied by code-graph hook/); // static fallback path
|
|
1019
|
+
assert.match(reason, /did NOT run/);
|
|
1020
|
+
assert.match(reason, /cargo test -q/);
|
|
1021
|
+
const rec = JSON.parse(fsE2e.readFileSync(
|
|
1022
|
+
pathE2e.join(fixture.dir, '.code-graph', 'recommendations.jsonl'), 'utf8').trim());
|
|
1023
|
+
assert.equal(rec.answered, false);
|
|
1024
|
+
assert.equal(rec.tail, true);
|
|
1025
|
+
} finally {
|
|
1026
|
+
cleanupFixture(fixture, cmd);
|
|
1027
|
+
}
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
test('e2e: simple (non-compound) denied grep → no tail field in the deny record', () => {
|
|
1031
|
+
const uniq = `StubNoTail${Date.now()}`;
|
|
1032
|
+
const fixture = e2eFixture(
|
|
1033
|
+
`process.stdout.write('src/foo.rs:7 fn ' + process.argv[3] + '()\\n');`);
|
|
1034
|
+
const cmd = `grep -rn "${uniq}" src/`;
|
|
1035
|
+
try {
|
|
1036
|
+
const res = runHook(cmd, fixture);
|
|
1037
|
+
const out = JSON.parse(res.stdout);
|
|
1038
|
+
assert.equal(out.hookSpecificOutput.permissionDecision, 'deny');
|
|
1039
|
+
const rec = JSON.parse(fsE2e.readFileSync(
|
|
1040
|
+
pathE2e.join(fixture.dir, '.code-graph', 'recommendations.jsonl'), 'utf8').trim());
|
|
1041
|
+
assert.equal(rec.action, 'deny');
|
|
1042
|
+
assert.equal('tail' in rec, false);
|
|
1043
|
+
} finally {
|
|
1044
|
+
cleanupFixture(fixture, cmd);
|
|
1045
|
+
}
|
|
1046
|
+
});
|
|
1047
|
+
|
|
906
1048
|
// ── v0.48 subdir-cwd dark fix: resolveProjectRoot / rebaseRelativePaths ──
|
|
907
1049
|
// daagu 2026-06-11: the persistent shell `cd backend/` darkened 38/40
|
|
908
1050
|
// head-greps for the rest of the night — gate 5 checked process.cwd() only.
|
|
@@ -6,7 +6,7 @@ const fs = require('fs');
|
|
|
6
6
|
const {
|
|
7
7
|
install, update, readManifest, getPluginVersion, checkScopeConflict,
|
|
8
8
|
cleanupDisabledStatusline, isPluginInactive, readJson, CACHE_DIR,
|
|
9
|
-
settingsPath,
|
|
9
|
+
settingsPath, isStaleRelicContext,
|
|
10
10
|
} = require('./lifecycle');
|
|
11
11
|
const { readBinaryVersion, isDevMode, getNewestMtime } = require('./version-utils');
|
|
12
12
|
const { maybeAutoAdopt, isAdopted } = require('./adopt');
|
|
@@ -55,6 +55,15 @@ function launchBackgroundAutoUpdate(spawnFn = spawn, env = process.env) {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
function syncLifecycleConfig() {
|
|
58
|
+
// v0.49.1: stale-relic guard. A still-running Claude Code process fires
|
|
59
|
+
// SessionStart from the plugin-cache dir it loaded at startup; after
|
|
60
|
+
// auto-update installs a newer version, those old scripts would see
|
|
61
|
+
// `manifest.version !== currentVersion` below and — direction-blind —
|
|
62
|
+
// call update(), dragging manifest + every settings.json hook path back
|
|
63
|
+
// to the old dir (upgrade↔downgrade ping-pong, observed live 2026-06-12).
|
|
64
|
+
// installed_plugins.json is the authority on which install may self-heal.
|
|
65
|
+
if (isStaleRelicContext()) return 'deferred-to-active-install';
|
|
66
|
+
|
|
58
67
|
const manifest = readManifest();
|
|
59
68
|
const currentVersion = getPluginVersion();
|
|
60
69
|
|
|
@@ -81,6 +90,14 @@ function syncLifecycleConfig() {
|
|
|
81
90
|
install();
|
|
82
91
|
return 'self-healed-bad-path';
|
|
83
92
|
}
|
|
93
|
+
// v0.49.1: also self-heal when the composite path exists but is not the one
|
|
94
|
+
// we'd write now (old plugin-cache version dir that still exists on disk —
|
|
95
|
+
// invisible to the existence check above; same fault class as the binary pin).
|
|
96
|
+
const { compositeCommand } = require('./lifecycle');
|
|
97
|
+
if (settings.statusLine.command !== compositeCommand()) {
|
|
98
|
+
install();
|
|
99
|
+
return 'self-healed-stale-statusline';
|
|
100
|
+
}
|
|
84
101
|
// Self-heal if any hook command points to a non-existent script (path pollution)
|
|
85
102
|
if (settings.hooks) {
|
|
86
103
|
for (const entries of Object.values(settings.hooks)) {
|
|
@@ -101,18 +118,20 @@ function syncLifecycleConfig() {
|
|
|
101
118
|
// (e.g. user manually edited settings.json, or settings.json got rewritten
|
|
102
119
|
// by another tool that didn't preserve our entries). Without this, the
|
|
103
120
|
// user silently loses PreToolUse/PostToolUse hooks until next plugin update.
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
121
|
+
// v0.49.1: upgraded from matcher-presence to surveyHookCoverage so a
|
|
122
|
+
// present-but-stale command path (old plugin-cache version dir that still
|
|
123
|
+
// exists) also heals. Previously only doctor checked staleness, so if the
|
|
124
|
+
// auto-update re-register step failed silently, users kept running old hook
|
|
125
|
+
// code indefinitely — the settings.json sibling of the binary-pin bug.
|
|
126
|
+
const { surveyHookCoverage } = require('./lifecycle');
|
|
127
|
+
const cov = surveyHookCoverage(settings);
|
|
128
|
+
if (cov.missing.length > 0) {
|
|
129
|
+
install();
|
|
130
|
+
return 'self-healed-missing-settings-hook';
|
|
131
|
+
}
|
|
132
|
+
if (cov.stale.length > 0) {
|
|
133
|
+
install();
|
|
134
|
+
return 'self-healed-stale-settings-hook';
|
|
116
135
|
}
|
|
117
136
|
return 'noop';
|
|
118
137
|
}
|
|
@@ -298,6 +317,11 @@ function runSessionInit() {
|
|
|
298
317
|
}
|
|
299
318
|
|
|
300
319
|
const lifecycle = syncLifecycleConfig();
|
|
320
|
+
// v0.49.1: a stale relic (see isStaleRelicContext) must not write ANY
|
|
321
|
+
// versioned state — that includes the adoption template: maybeAutoAdopt's
|
|
322
|
+
// drift-refresh would "refresh" MEMORY.md back to the relic's OLD shipped
|
|
323
|
+
// template, the adoption-surface twin of the settings.json downgrade war.
|
|
324
|
+
const isRelic = lifecycle === 'deferred-to-active-install';
|
|
301
325
|
|
|
302
326
|
// Verify binary availability — catch issues early with actionable diagnostics
|
|
303
327
|
const binaryCheck = verifyBinary();
|
|
@@ -308,7 +332,7 @@ function runSessionInit() {
|
|
|
308
332
|
// v0.9.0 C' 上下文感知默认:插件模式下首次 SessionStart 自动 adopt。
|
|
309
333
|
// v0.11.0: 已 adopt 的项目如果 shipped template 漂移也会触发一次刷新。
|
|
310
334
|
// 两种情况都发一次 stderr 提示,让用户知道发生了什么 + 如何回退。
|
|
311
|
-
const autoAdopt = maybeAutoAdopt({ scriptPath: __dirname });
|
|
335
|
+
const autoAdopt = isRelic ? { attempted: false, result: null } : maybeAutoAdopt({ scriptPath: __dirname });
|
|
312
336
|
if (autoAdopt.attempted && autoAdopt.result && autoAdopt.result.ok) {
|
|
313
337
|
if (autoAdopt.reason === 'refreshed') {
|
|
314
338
|
process.stderr.write(
|
|
@@ -42,6 +42,9 @@ type: reference
|
|
|
42
42
|
> ToolSearch 加载),而 Bash 永远在线——真实编程夜(2026-06-12)观测到的全部
|
|
43
43
|
> 转化都是 CLI 调用。结构化查询的最快路径是 Bash 直呼
|
|
44
44
|
> `code-graph-mcp callgraph X / show X / overview <dir> / grep "pat" / impact X`。
|
|
45
|
+
> `grep` 是 drop-in 替代:`-F` 字面 / `-i` / `-w` / `-l` / `-A/-B/-C N` 上下文 /
|
|
46
|
+
> 多路径 / `--max-count 0`,退出码兼容 grep(0/1/2),召回达 git-grep 级
|
|
47
|
+
> (tracked-but-gitignored 也能搜到),每条命中标注所属 fn/class。
|
|
45
48
|
>
|
|
46
49
|
> v0.10.0 起:tools/list 默认只暴露 7 个核心工具;下表"进阶 5"中的工具
|
|
47
50
|
> 已从 tools/list 隐藏以节省 session 启动 tokens。**Claude Code 里请走 CLI
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdsrs/code-graph",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.51.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.51.0",
|
|
39
|
+
"@sdsrs/code-graph-linux-arm64": "0.51.0",
|
|
40
|
+
"@sdsrs/code-graph-darwin-x64": "0.51.0",
|
|
41
|
+
"@sdsrs/code-graph-darwin-arm64": "0.51.0",
|
|
42
|
+
"@sdsrs/code-graph-win32-x64": "0.51.0"
|
|
43
43
|
}
|
|
44
44
|
}
|