@sdsrs/code-graph 0.16.8 → 0.17.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/adopt.js +72 -9
- package/claude-plugin/scripts/adopt.test.js +111 -8
- package/claude-plugin/scripts/find-binary.js +32 -1
- package/claude-plugin/scripts/find-binary.test.js +38 -1
- package/claude-plugin/scripts/session-init.js +17 -7
- package/claude-plugin/scripts/session-init.test.js +32 -13
- package/claude-plugin/templates/plugin_code_graph_mcp.md +12 -5
- package/package.json +8 -7
|
@@ -11,6 +11,18 @@ const os = require('os');
|
|
|
11
11
|
|
|
12
12
|
const SENTINEL_BEGIN = '<!-- code-graph-mcp:begin v1 -->';
|
|
13
13
|
const SENTINEL_END = '<!-- code-graph-mcp:end -->';
|
|
14
|
+
// Collision-detection marker. Slug encoding `[^a-zA-Z0-9-]→'-'` is lossy,
|
|
15
|
+
// so two cwds (e.g. /foo/bar and /foo bar) can resolve to the same memory
|
|
16
|
+
// dir. Adopt records its absolute cwd as the file's first-line HTML comment;
|
|
17
|
+
// re-adopt from a different cwd surfaces a warning.
|
|
18
|
+
const ADOPTED_BY_RE = /^<!-- adopted-by: (.+?) -->\r?\n?/;
|
|
19
|
+
function readAdoptedBy(filePath) {
|
|
20
|
+
try {
|
|
21
|
+
const first = fs.readFileSync(filePath, 'utf8').split('\n', 1)[0];
|
|
22
|
+
const m = first.match(/^<!-- adopted-by: (.+?) -->/);
|
|
23
|
+
return m ? m[1] : null;
|
|
24
|
+
} catch { return null; }
|
|
25
|
+
}
|
|
14
26
|
const INDEX_LINE = [
|
|
15
27
|
'- [code-graph-mcp](plugin_code_graph_mcp.md) — v0.10.0 起 tools/list 默认 7 核心 + 5 隐藏可调(省启动 token)',
|
|
16
28
|
' - 核心 7(默认暴露):`get_call_graph`/`module_overview`/`semantic_code_search`/`ast_search`/`find_references`/`get_ast_node`/`project_map`',
|
|
@@ -68,20 +80,49 @@ function platformGuard() {
|
|
|
68
80
|
return null;
|
|
69
81
|
}
|
|
70
82
|
|
|
83
|
+
// Project-marker check: cwd looks like a real project (not /tmp / $HOME).
|
|
84
|
+
// Used to gate auto-mkdir of the auto-memory dir so adopt doesn't pollute
|
|
85
|
+
// random directories. Mirrors the markers Claude Code itself recognizes.
|
|
86
|
+
const PROJECT_MARKERS = [
|
|
87
|
+
'.git', '.code-graph', 'package.json', 'Cargo.toml',
|
|
88
|
+
'pyproject.toml', 'go.mod', 'pom.xml', 'build.gradle',
|
|
89
|
+
];
|
|
90
|
+
function isProjectRoot(cwd) {
|
|
91
|
+
return PROJECT_MARKERS.some(m => fs.existsSync(path.join(cwd, m)));
|
|
92
|
+
}
|
|
93
|
+
|
|
71
94
|
function adopt({ cwd, home, templatePath } = {}) {
|
|
72
95
|
const blocked = platformGuard();
|
|
73
96
|
if (blocked) return blocked;
|
|
74
97
|
|
|
98
|
+
const effectiveCwd = cwd || process.cwd();
|
|
75
99
|
const dir = memoryDir(cwd, home);
|
|
76
100
|
if (!fs.existsSync(dir)) {
|
|
77
|
-
|
|
101
|
+
// Auto-create only when cwd has a project marker. Without markers the
|
|
102
|
+
// user is likely in /tmp or $HOME, where adopt would litter
|
|
103
|
+
// ~/.claude/projects/ with bogus slugs.
|
|
104
|
+
if (!isProjectRoot(effectiveCwd)) {
|
|
105
|
+
return { ok: false, reason: 'not-a-project', dir, cwd: effectiveCwd };
|
|
106
|
+
}
|
|
107
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
78
108
|
}
|
|
79
109
|
const target = path.join(dir, TARGET_NAME);
|
|
80
110
|
const tpl = templatePath || TEMPLATE_PATH;
|
|
81
111
|
if (!fs.existsSync(tpl)) {
|
|
82
112
|
return { ok: false, reason: 'no-template', template: tpl };
|
|
83
113
|
}
|
|
84
|
-
|
|
114
|
+
// Slug-collision detection: read prior adopted-by marker before overwrite.
|
|
115
|
+
let collisionWith = null;
|
|
116
|
+
if (fs.existsSync(target)) {
|
|
117
|
+
const prevCwd = readAdoptedBy(target);
|
|
118
|
+
if (prevCwd && prevCwd !== effectiveCwd) collisionWith = prevCwd;
|
|
119
|
+
}
|
|
120
|
+
// Write marker + template. Marker is HTML comment → invisible in rendered
|
|
121
|
+
// markdown but preserved by needsRefresh's bytewise compare (skipped via
|
|
122
|
+
// ADOPTED_BY_RE strip below).
|
|
123
|
+
const tplBody = fs.readFileSync(tpl);
|
|
124
|
+
const marker = Buffer.from(`<!-- adopted-by: ${effectiveCwd} -->\n`);
|
|
125
|
+
fs.writeFileSync(target, Buffer.concat([marker, tplBody]));
|
|
85
126
|
|
|
86
127
|
const indexPath = path.join(dir, 'MEMORY.md');
|
|
87
128
|
const index = fs.existsSync(indexPath) ? fs.readFileSync(indexPath, 'utf8') : '# Memory Index\n';
|
|
@@ -89,14 +130,14 @@ function adopt({ cwd, home, templatePath } = {}) {
|
|
|
89
130
|
|
|
90
131
|
// Already-adopted-and-well-formed: skip the write entirely.
|
|
91
132
|
if (index.includes(desiredBlock)) {
|
|
92
|
-
return { ok: true, target, indexPath, indexed: false, healed: false };
|
|
133
|
+
return { ok: true, target, indexPath, indexed: false, healed: false, collisionWith };
|
|
93
134
|
}
|
|
94
135
|
|
|
95
136
|
const cleaned = stripSentinelBlock(index);
|
|
96
137
|
const healed = cleaned !== index;
|
|
97
138
|
const base = cleaned.endsWith('\n') ? cleaned : cleaned + '\n';
|
|
98
139
|
fs.writeFileSync(indexPath, base + desiredBlock + '\n');
|
|
99
|
-
return { ok: true, target, indexPath, indexed: true, healed };
|
|
140
|
+
return { ok: true, target, indexPath, indexed: true, healed, collisionWith };
|
|
100
141
|
}
|
|
101
142
|
|
|
102
143
|
// v0.9.0 — "已 adopt" 判定:template 文件在 + MEMORY.md 内有我们的 sentinel 块。
|
|
@@ -124,7 +165,15 @@ function needsRefresh({ cwd, home, templatePath } = {}) {
|
|
|
124
165
|
}
|
|
125
166
|
const shipped = fs.readFileSync(tpl);
|
|
126
167
|
const current = fs.readFileSync(target);
|
|
127
|
-
|
|
168
|
+
// Strip the leading "<!-- adopted-by: ... -->\n" collision marker (D fix)
|
|
169
|
+
// before bytewise comparing — its presence/path naturally diverges from
|
|
170
|
+
// the shipped template.
|
|
171
|
+
let body = current;
|
|
172
|
+
const nl = current.indexOf(0x0a);
|
|
173
|
+
if (nl > 0 && ADOPTED_BY_RE.test(current.subarray(0, nl + 1).toString())) {
|
|
174
|
+
body = current.subarray(nl + 1);
|
|
175
|
+
}
|
|
176
|
+
if (!shipped.equals(body)) return true;
|
|
128
177
|
const index = fs.readFileSync(indexPath, 'utf8');
|
|
129
178
|
const desiredBlock = `${SENTINEL_BEGIN}\n${INDEX_LINE}\n${SENTINEL_END}`;
|
|
130
179
|
return !index.includes(desiredBlock);
|
|
@@ -198,18 +247,31 @@ function formatResult(action, result) {
|
|
|
198
247
|
return `[code-graph] Memory dir not found: ${result.dir}\n` +
|
|
199
248
|
' Run \`claude\` at least once in this project to create it.';
|
|
200
249
|
}
|
|
250
|
+
if (result.reason === 'not-a-project') {
|
|
251
|
+
return `[code-graph] Not a project root: ${result.cwd}\n` +
|
|
252
|
+
' No project marker (.git, Cargo.toml, package.json, pyproject.toml, ...).\n' +
|
|
253
|
+
' cd into a real project before running adopt.';
|
|
254
|
+
}
|
|
201
255
|
if (result.reason === 'no-template') {
|
|
202
256
|
return `[code-graph] Template missing: ${result.template}`;
|
|
203
257
|
}
|
|
204
258
|
return `[code-graph] adopt failed: ${result.reason || 'unknown'}`;
|
|
205
259
|
}
|
|
206
260
|
const lines = [`[code-graph] Adopted → ${result.target}`];
|
|
261
|
+
if (result.collisionWith) {
|
|
262
|
+
lines.push(`[code-graph] ⚠ slug collision: this dir was previously adopted by ${result.collisionWith}.`);
|
|
263
|
+
lines.push('[code-graph] Memory dir is shared — sentinels overwritten. ' +
|
|
264
|
+
'Investigate path encoding clash (Claude Code slug = path with non-[a-zA-Z0-9-] → "-").');
|
|
265
|
+
}
|
|
207
266
|
if (result.healed) lines.push(`[code-graph] Healed malformed sentinel block → ${result.indexPath}`);
|
|
208
267
|
else if (result.indexed) lines.push(`[code-graph] Indexed → ${result.indexPath}`);
|
|
209
268
|
else lines.push(`[code-graph] Index already up-to-date — no write`);
|
|
210
|
-
// v0.
|
|
211
|
-
|
|
212
|
-
|
|
269
|
+
// v0.17.0: SessionStart project_map injection is OFF by default (regardless
|
|
270
|
+
// of adoption). Adoption now only governs MEMORY.md sentinel + decision-table
|
|
271
|
+
// refresh; the noisy hook needs an explicit opt-in.
|
|
272
|
+
lines.push('[code-graph] Active. SessionStart project_map injection: OFF (default).');
|
|
273
|
+
lines.push('[code-graph] Opt in to map dump: CODE_GRAPH_VERBOSE_HOOKS=1');
|
|
274
|
+
lines.push('[code-graph] Legacy override: CODE_GRAPH_QUIET_HOOKS=0 (force noisy) / =1 (force quiet)');
|
|
213
275
|
return lines.join('\n');
|
|
214
276
|
}
|
|
215
277
|
if (action === 'unadopt') {
|
|
@@ -231,6 +293,7 @@ if (require.main === module) {
|
|
|
231
293
|
|
|
232
294
|
module.exports = {
|
|
233
295
|
adopt, unadopt, memoryDir, formatResult, stripSentinelBlock,
|
|
234
|
-
isAdopted, isPluginModeInstall, maybeAutoAdopt, needsRefresh,
|
|
296
|
+
isAdopted, isPluginModeInstall, maybeAutoAdopt, needsRefresh, isProjectRoot,
|
|
235
297
|
SENTINEL_BEGIN, SENTINEL_END, INDEX_LINE, TEMPLATE_PATH, TARGET_NAME,
|
|
298
|
+
PROJECT_MARKERS,
|
|
236
299
|
};
|
|
@@ -6,8 +6,9 @@ const path = require('path');
|
|
|
6
6
|
const os = require('os');
|
|
7
7
|
const {
|
|
8
8
|
adopt, unadopt, memoryDir, stripSentinelBlock,
|
|
9
|
-
isAdopted, isPluginModeInstall, maybeAutoAdopt, needsRefresh,
|
|
9
|
+
isAdopted, isPluginModeInstall, maybeAutoAdopt, needsRefresh, isProjectRoot,
|
|
10
10
|
SENTINEL_BEGIN, SENTINEL_END, INDEX_LINE, TEMPLATE_PATH, TARGET_NAME,
|
|
11
|
+
PROJECT_MARKERS,
|
|
11
12
|
} = require('./adopt');
|
|
12
13
|
|
|
13
14
|
function makeSandbox() {
|
|
@@ -84,13 +85,17 @@ test('adopt preserves existing MEMORY.md content and appends', () => {
|
|
|
84
85
|
} finally { sb.cleanup(); }
|
|
85
86
|
});
|
|
86
87
|
|
|
87
|
-
test('adopt fails gracefully when
|
|
88
|
+
test('adopt fails gracefully when cwd is not a project root', () => {
|
|
89
|
+
// v0.16.9: behavior change — adopt now mkdir's the memory dir when cwd has
|
|
90
|
+
// a project marker (.git / Cargo.toml / package.json / ...). Bare mkdtemp
|
|
91
|
+
// without markers still fails with the more specific 'not-a-project' reason
|
|
92
|
+
// to prevent /tmp pollution.
|
|
88
93
|
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-adopt-home-'));
|
|
89
94
|
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-adopt-cwd-'));
|
|
90
95
|
try {
|
|
91
96
|
const res = adopt({ cwd, home });
|
|
92
97
|
assert.strictEqual(res.ok, false);
|
|
93
|
-
assert.strictEqual(res.reason, '
|
|
98
|
+
assert.strictEqual(res.reason, 'not-a-project');
|
|
94
99
|
} finally {
|
|
95
100
|
fs.rmSync(home, { recursive: true, force: true });
|
|
96
101
|
fs.rmSync(cwd, { recursive: true, force: true });
|
|
@@ -329,7 +334,10 @@ test('maybeAutoAdopt runs adopt when plugin-mode + unadopted + no opt-out', () =
|
|
|
329
334
|
} finally { sb.cleanup(); }
|
|
330
335
|
});
|
|
331
336
|
|
|
332
|
-
test('maybeAutoAdopt
|
|
337
|
+
test('maybeAutoAdopt fails with not-a-project when cwd has no project marker', () => {
|
|
338
|
+
// v0.16.9: bare mkdtemp cwd without .git/Cargo.toml/etc. surfaces
|
|
339
|
+
// 'not-a-project' so plugin-mode auto-adopt doesn't litter ~/.claude/projects/
|
|
340
|
+
// with bogus slugs from non-project working directories.
|
|
333
341
|
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-adopt-home-'));
|
|
334
342
|
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-adopt-cwd-'));
|
|
335
343
|
try {
|
|
@@ -338,10 +346,9 @@ test('maybeAutoAdopt returns no-memory-dir when project memory missing', () => {
|
|
|
338
346
|
scriptPath: '/home/u/.claude/plugins/cache/code-graph-mcp/scripts',
|
|
339
347
|
env: {},
|
|
340
348
|
});
|
|
341
|
-
// Plugin-mode + not adopted + no opt-out → attempt runs, but adopt() fails gracefully
|
|
342
349
|
assert.strictEqual(res.attempted, true);
|
|
343
350
|
assert.strictEqual(res.result.ok, false);
|
|
344
|
-
assert.strictEqual(res.result.reason, '
|
|
351
|
+
assert.strictEqual(res.result.reason, 'not-a-project');
|
|
345
352
|
} finally {
|
|
346
353
|
fs.rmSync(home, { recursive: true, force: true });
|
|
347
354
|
fs.rmSync(cwd, { recursive: true, force: true });
|
|
@@ -400,10 +407,14 @@ test('maybeAutoAdopt refreshes drifted target on re-run (reason=refreshed)', ()
|
|
|
400
407
|
assert.strictEqual(res.attempted, true);
|
|
401
408
|
assert.strictEqual(res.reason, 'refreshed');
|
|
402
409
|
assert.strictEqual(res.result.ok, true);
|
|
403
|
-
// Target now matches shipped template
|
|
410
|
+
// Target now matches shipped template (after stripping the leading
|
|
411
|
+
// "<!-- adopted-by: ... -->\n" collision marker added by adopt v0.16.9).
|
|
404
412
|
const shipped = fs.readFileSync(TEMPLATE_PATH);
|
|
405
413
|
const current = fs.readFileSync(target);
|
|
406
|
-
|
|
414
|
+
const nl = current.indexOf(0x0a);
|
|
415
|
+
const body = nl > 0 && /^<!-- adopted-by: /.test(current.subarray(0, nl).toString())
|
|
416
|
+
? current.subarray(nl + 1) : current;
|
|
417
|
+
assert.ok(shipped.equals(body), 'target re-synced to shipped template');
|
|
407
418
|
// Sentinel preserved in MEMORY.md
|
|
408
419
|
assert.strictEqual(isAdopted({ cwd: sb.cwd, home: sb.home }), true);
|
|
409
420
|
} finally { sb.cleanup(); }
|
|
@@ -482,3 +493,95 @@ test('Windows platform is rejected with clear reason', { skip: process.platform
|
|
|
482
493
|
Object.defineProperty(process, 'platform', { value: orig, configurable: true });
|
|
483
494
|
}
|
|
484
495
|
});
|
|
496
|
+
|
|
497
|
+
// ─── C fix: project-marker mkdir ─────────────────────────────────────────
|
|
498
|
+
|
|
499
|
+
test('isProjectRoot detects each marker', () => {
|
|
500
|
+
for (const marker of PROJECT_MARKERS) {
|
|
501
|
+
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-marker-'));
|
|
502
|
+
try {
|
|
503
|
+
assert.strictEqual(isProjectRoot(cwd), false, `bare cwd should not be a project`);
|
|
504
|
+
const markerPath = path.join(cwd, marker);
|
|
505
|
+
// Some markers are directories (.git, .code-graph), others are files.
|
|
506
|
+
if (marker.startsWith('.')) fs.mkdirSync(markerPath);
|
|
507
|
+
else fs.writeFileSync(markerPath, '');
|
|
508
|
+
assert.strictEqual(isProjectRoot(cwd), true, `${marker} should make cwd a project`);
|
|
509
|
+
} finally {
|
|
510
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
test('adopt auto-creates memory dir when cwd has a project marker', () => {
|
|
516
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-adopt-home-'));
|
|
517
|
+
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-adopt-cwd-'));
|
|
518
|
+
try {
|
|
519
|
+
// Add a project marker so adopt is allowed to create the memory dir.
|
|
520
|
+
fs.writeFileSync(path.join(cwd, 'package.json'), '{}');
|
|
521
|
+
// The memory dir does NOT exist yet — pre-fix behavior errored 'no-memory-dir'.
|
|
522
|
+
const dir = memoryDir(cwd, home);
|
|
523
|
+
assert.strictEqual(fs.existsSync(dir), false);
|
|
524
|
+
|
|
525
|
+
const res = adopt({ cwd, home });
|
|
526
|
+
assert.strictEqual(res.ok, true, `expected ok, got ${JSON.stringify(res)}`);
|
|
527
|
+
assert.strictEqual(fs.existsSync(dir), true, 'memory dir auto-created');
|
|
528
|
+
assert.strictEqual(fs.existsSync(path.join(dir, TARGET_NAME)), true, 'plugin file written');
|
|
529
|
+
} finally {
|
|
530
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
531
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
532
|
+
}
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
// ─── D fix: slug collision marker ────────────────────────────────────────
|
|
536
|
+
|
|
537
|
+
test('adopt writes adopted-by marker as first line of plugin file', () => {
|
|
538
|
+
const sb = makeSandbox();
|
|
539
|
+
try {
|
|
540
|
+
adopt({ cwd: sb.cwd, home: sb.home });
|
|
541
|
+
const target = path.join(sb.dir, TARGET_NAME);
|
|
542
|
+
const firstLine = fs.readFileSync(target, 'utf8').split('\n', 1)[0];
|
|
543
|
+
assert.match(firstLine, /^<!-- adopted-by: .* -->$/, `expected adopted-by marker, got: ${firstLine}`);
|
|
544
|
+
assert.ok(firstLine.includes(sb.cwd), `marker should embed absolute cwd: ${firstLine}`);
|
|
545
|
+
} finally { sb.cleanup(); }
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
test('adopt detects slug collision when same memory dir is re-adopted from a different cwd', () => {
|
|
549
|
+
// Simulate two real cwds whose paths slugify to the same string. Here we
|
|
550
|
+
// skip real path encoding and just write a file pretending it came from a
|
|
551
|
+
// different cwd, then re-adopt — collision detection reads the prior marker.
|
|
552
|
+
const sb = makeSandbox();
|
|
553
|
+
try {
|
|
554
|
+
adopt({ cwd: sb.cwd, home: sb.home });
|
|
555
|
+
const target = path.join(sb.dir, TARGET_NAME);
|
|
556
|
+
// Tamper: rewrite the marker to look like a different cwd adopted first.
|
|
557
|
+
const body = fs.readFileSync(target, 'utf8').split('\n').slice(1).join('\n');
|
|
558
|
+
fs.writeFileSync(target, '<!-- adopted-by: /imaginary/other-project -->\n' + body);
|
|
559
|
+
|
|
560
|
+
const res = adopt({ cwd: sb.cwd, home: sb.home });
|
|
561
|
+
assert.strictEqual(res.ok, true);
|
|
562
|
+
assert.strictEqual(res.collisionWith, '/imaginary/other-project',
|
|
563
|
+
`expected collisionWith to surface prior cwd, got ${res.collisionWith}`);
|
|
564
|
+
} finally { sb.cleanup(); }
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
test('adopt collisionWith is null when re-adopting from same cwd (idempotent)', () => {
|
|
568
|
+
const sb = makeSandbox();
|
|
569
|
+
try {
|
|
570
|
+
adopt({ cwd: sb.cwd, home: sb.home });
|
|
571
|
+
const res = adopt({ cwd: sb.cwd, home: sb.home });
|
|
572
|
+
assert.strictEqual(res.ok, true);
|
|
573
|
+
assert.strictEqual(res.collisionWith, null);
|
|
574
|
+
} finally { sb.cleanup(); }
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
test('needsRefresh ignores the adopted-by marker when bytewise comparing', () => {
|
|
578
|
+
// Critical: the marker we add to target makes target ≠ template byte-for-byte.
|
|
579
|
+
// needsRefresh must skip the leading marker line before compare; otherwise
|
|
580
|
+
// every SessionStart would re-write the file and burn IO on a no-op.
|
|
581
|
+
const sb = makeSandbox();
|
|
582
|
+
try {
|
|
583
|
+
adopt({ cwd: sb.cwd, home: sb.home });
|
|
584
|
+
assert.strictEqual(needsRefresh({ cwd: sb.cwd, home: sb.home }), false,
|
|
585
|
+
'needsRefresh should be false right after adopt — marker must not trigger drift');
|
|
586
|
+
} finally { sb.cleanup(); }
|
|
587
|
+
});
|
|
@@ -4,6 +4,7 @@ const { execFileSync } = require('child_process');
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
const os = require('os');
|
|
7
|
+
const { readBinaryVersion } = require('./version-utils');
|
|
7
8
|
|
|
8
9
|
const PLATFORM = os.platform();
|
|
9
10
|
const ARCH = os.arch();
|
|
@@ -11,6 +12,24 @@ const CACHE_FILE = path.join(os.homedir(), '.cache', 'code-graph', 'binary-path'
|
|
|
11
12
|
const BINARY_NAME = PLATFORM === 'win32' ? 'code-graph-mcp.exe' : 'code-graph-mcp';
|
|
12
13
|
const PLATFORM_PKG = `@sdsrs/code-graph-${PLATFORM}-${ARCH}`;
|
|
13
14
|
|
|
15
|
+
/** Read the npm pkg version from this script's package.json (claude-plugin/../package.json). */
|
|
16
|
+
function getPackageVersion() {
|
|
17
|
+
try { return require('../../package.json').version; }
|
|
18
|
+
catch { return null; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Compare semver-ish "M.m.p" strings; returns -1, 0, or 1. Non-numeric parts → 0. */
|
|
22
|
+
function compareVersions(a, b) {
|
|
23
|
+
const pa = String(a).split('.').map(s => parseInt(s, 10));
|
|
24
|
+
const pb = String(b).split('.').map(s => parseInt(s, 10));
|
|
25
|
+
for (let i = 0; i < 3; i++) {
|
|
26
|
+
const x = Number.isFinite(pa[i]) ? pa[i] : 0;
|
|
27
|
+
const y = Number.isFinite(pb[i]) ? pb[i] : 0;
|
|
28
|
+
if (x !== y) return x < y ? -1 : 1;
|
|
29
|
+
}
|
|
30
|
+
return 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
14
33
|
/**
|
|
15
34
|
* Candidate paths for npm global `node_modules`.
|
|
16
35
|
*
|
|
@@ -157,8 +176,19 @@ function findBinaryUncached() {
|
|
|
157
176
|
}
|
|
158
177
|
|
|
159
178
|
// --- Auto-update cache (binary downloaded directly from GitHub release) ---
|
|
179
|
+
// Cache wins when its version >= the npm pkg version. After `npm update`
|
|
180
|
+
// refreshes the platform-pkg, an older auto-update cache binary must NOT
|
|
181
|
+
// shadow the freshly-installed one; this version check prevents the
|
|
182
|
+
// upgrade-race where users keep running stale binary until auto-update fires.
|
|
160
183
|
const autoUpdateBin = path.join(os.homedir(), '.cache', 'code-graph', 'bin', BINARY_NAME);
|
|
161
|
-
if (isNativeBinary(autoUpdateBin))
|
|
184
|
+
if (isNativeBinary(autoUpdateBin)) {
|
|
185
|
+
const cacheVer = readBinaryVersion(autoUpdateBin);
|
|
186
|
+
const pkgVer = getPackageVersion();
|
|
187
|
+
if (!pkgVer || !cacheVer || compareVersions(cacheVer, pkgVer) >= 0) {
|
|
188
|
+
return autoUpdateBin;
|
|
189
|
+
}
|
|
190
|
+
// Cache is older than npm pkg — fall through to platform-pkg.
|
|
191
|
+
}
|
|
162
192
|
|
|
163
193
|
// --- Platform-specific npm package (@sdsrs/code-graph-{os}-{arch}) ---
|
|
164
194
|
const platformBin = findPlatformBinary();
|
|
@@ -212,6 +242,7 @@ function clearCache() {
|
|
|
212
242
|
module.exports = {
|
|
213
243
|
findBinary, findBinaryUncached, clearCache,
|
|
214
244
|
globalNodeModulesCandidates, findPlatformBinary,
|
|
245
|
+
getPackageVersion, compareVersions,
|
|
215
246
|
CACHE_FILE, BINARY_NAME, PLATFORM_PKG,
|
|
216
247
|
};
|
|
217
248
|
|
|
@@ -5,7 +5,8 @@ const fs = require('fs');
|
|
|
5
5
|
const os = require('os');
|
|
6
6
|
const path = require('path');
|
|
7
7
|
|
|
8
|
-
const { globalNodeModulesCandidates, findPlatformBinary, BINARY_NAME
|
|
8
|
+
const { globalNodeModulesCandidates, findPlatformBinary, BINARY_NAME,
|
|
9
|
+
compareVersions, getPackageVersion } = require('./find-binary');
|
|
9
10
|
|
|
10
11
|
function mkDir(t, prefix) {
|
|
11
12
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
@@ -108,3 +109,39 @@ test('findPlatformBinary returns null when no platform pkg installed anywhere re
|
|
|
108
109
|
}
|
|
109
110
|
assert.equal(real, null);
|
|
110
111
|
});
|
|
112
|
+
|
|
113
|
+
// ─── compareVersions (B fix: cache version invalidation helper) ───────────
|
|
114
|
+
|
|
115
|
+
test('compareVersions: equal', () => {
|
|
116
|
+
assert.equal(compareVersions('1.2.3', '1.2.3'), 0);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('compareVersions: cache older than pkg', () => {
|
|
120
|
+
// After `npm update` to 0.16.8, an auto-update cache from 0.16.7 must NOT
|
|
121
|
+
// shadow the freshly-installed platform-pkg binary. Returns -1 here so
|
|
122
|
+
// findBinaryUncached falls through to platform-pkg.
|
|
123
|
+
assert.equal(compareVersions('0.16.7', '0.16.8'), -1);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('compareVersions: cache newer than pkg', () => {
|
|
127
|
+
// Auto-update may legitimately be ahead of npm pkg (cache fetched 0.17.0
|
|
128
|
+
// before npm shipped it). Returns 1 → cache wins.
|
|
129
|
+
assert.equal(compareVersions('0.17.0', '0.16.8'), 1);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('compareVersions: minor and patch boundaries', () => {
|
|
133
|
+
assert.equal(compareVersions('1.0.0', '0.999.999'), 1);
|
|
134
|
+
assert.equal(compareVersions('1.10.0', '1.9.99'), 1); // numeric, not lexical
|
|
135
|
+
assert.equal(compareVersions('1.0.10', '1.0.9'), 1);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('compareVersions: tolerates non-numeric / short input', () => {
|
|
139
|
+
// Non-numeric → treated as 0; shorter strings padded with 0.
|
|
140
|
+
assert.equal(compareVersions('1.2', '1.2.0'), 0);
|
|
141
|
+
assert.equal(compareVersions('foo', '0.0.0'), 0);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('getPackageVersion reads root package.json', () => {
|
|
145
|
+
const v = getPackageVersion();
|
|
146
|
+
assert.match(v, /^\d+\.\d+\.\d+$/, `expected semver-ish, got: ${v}`);
|
|
147
|
+
});
|
|
@@ -11,13 +11,22 @@ const {
|
|
|
11
11
|
const { readBinaryVersion, isDevMode, getNewestMtime } = require('./version-utils');
|
|
12
12
|
const { maybeAutoAdopt, isAdopted } = require('./adopt');
|
|
13
13
|
|
|
14
|
-
// v0.
|
|
15
|
-
//
|
|
16
|
-
|
|
14
|
+
// v0.17.0 — quietHooks: unconditional quiet 默认。
|
|
15
|
+
// 项目地图与 MEMORY.md plugin contract + on-demand `project_map` 工具高度重叠,
|
|
16
|
+
// 默认每次 SessionStart 都注入 ≈2.3 KB 是不必要的常驻上下文成本。
|
|
17
|
+
// 优先级(高到低):
|
|
18
|
+
// 1. legacy CODE_GRAPH_QUIET_HOOKS='0' → forced noisy(向后兼容)
|
|
19
|
+
// 2. legacy CODE_GRAPH_QUIET_HOOKS='1' → forced quiet(向后兼容)
|
|
20
|
+
// 3. CODE_GRAPH_VERBOSE_HOOKS='1' → opt-in noisy(新)
|
|
21
|
+
// 4. 默认 → quiet
|
|
22
|
+
// `adopted` 参数已弃用(unconditional 默认不再依赖该信号),保留接口签名只为
|
|
23
|
+
// 不破坏既有调用 / 测试。
|
|
24
|
+
function computeQuietHooks({ env = {} } = {}) {
|
|
17
25
|
const envQuiet = env.CODE_GRAPH_QUIET_HOOKS;
|
|
18
26
|
if (envQuiet === '0') return false;
|
|
19
27
|
if (envQuiet === '1') return true;
|
|
20
|
-
return
|
|
28
|
+
if (env.CODE_GRAPH_VERBOSE_HOOKS === '1') return false;
|
|
29
|
+
return true;
|
|
21
30
|
}
|
|
22
31
|
|
|
23
32
|
function launchBackgroundAutoUpdate(spawnFn = spawn, env = process.env) {
|
|
@@ -277,10 +286,11 @@ function runSessionInit() {
|
|
|
277
286
|
}
|
|
278
287
|
}
|
|
279
288
|
|
|
280
|
-
// quietHooks:
|
|
281
|
-
//
|
|
289
|
+
// quietHooks: default quiet (project_map injection duplicates MEMORY.md +
|
|
290
|
+
// on-demand tool). CODE_GRAPH_VERBOSE_HOOKS=1 to opt in to the dump;
|
|
291
|
+
// legacy CODE_GRAPH_QUIET_HOOKS=0/1 still force the old behavior.
|
|
282
292
|
const adopted = isAdopted();
|
|
283
|
-
const quietHooks = computeQuietHooks({
|
|
293
|
+
const quietHooks = computeQuietHooks({ env: process.env });
|
|
284
294
|
|
|
285
295
|
const mapInjected = binaryCheck.available && !quietHooks ? injectProjectMap() : false;
|
|
286
296
|
const consistencyIssues = binaryCheck.available
|
|
@@ -84,27 +84,46 @@ test('consistencyCheck returns empty array when binary version matches plugin',
|
|
|
84
84
|
});
|
|
85
85
|
|
|
86
86
|
// ──────────────────────────────────────────────────────────────────────────
|
|
87
|
-
// v0.
|
|
87
|
+
// v0.17.0 — quietHooks: unconditional quiet default
|
|
88
|
+
// Priority: legacy QUIET_HOOKS=0/1 > new VERBOSE_HOOKS=1 > default true.
|
|
89
|
+
// `adopted` param is dead (unconditional default does not consult it) but
|
|
90
|
+
// the destructured signature still accepts it for backward compat.
|
|
88
91
|
// ──────────────────────────────────────────────────────────────────────────
|
|
89
92
|
|
|
90
|
-
test('computeQuietHooks:
|
|
91
|
-
assert.equal(computeQuietHooks({
|
|
92
|
-
assert.equal(computeQuietHooks({ adopted: false, env: { CODE_GRAPH_QUIET_HOOKS: '0' } }), false);
|
|
93
|
+
test('computeQuietHooks: legacy QUIET_HOOKS="0" forces noisy', () => {
|
|
94
|
+
assert.equal(computeQuietHooks({ env: { CODE_GRAPH_QUIET_HOOKS: '0' } }), false);
|
|
93
95
|
});
|
|
94
96
|
|
|
95
|
-
test('computeQuietHooks:
|
|
96
|
-
assert.equal(computeQuietHooks({
|
|
97
|
-
assert.equal(computeQuietHooks({ adopted: false, env: { CODE_GRAPH_QUIET_HOOKS: '1' } }), true);
|
|
97
|
+
test('computeQuietHooks: legacy QUIET_HOOKS="1" forces quiet', () => {
|
|
98
|
+
assert.equal(computeQuietHooks({ env: { CODE_GRAPH_QUIET_HOOKS: '1' } }), true);
|
|
98
99
|
});
|
|
99
100
|
|
|
100
|
-
test('computeQuietHooks:
|
|
101
|
-
assert.equal(computeQuietHooks({
|
|
102
|
-
|
|
101
|
+
test('computeQuietHooks: VERBOSE_HOOKS="1" opts in to noisy', () => {
|
|
102
|
+
assert.equal(computeQuietHooks({ env: { CODE_GRAPH_VERBOSE_HOOKS: '1' } }), false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('computeQuietHooks: legacy QUIET_HOOKS="1" wins over VERBOSE_HOOKS="1"', () => {
|
|
106
|
+
// Conflicting opt-ins: legacy explicit-quiet wins over new verbose opt-in.
|
|
107
|
+
// (Legacy QUIET_HOOKS="0" + VERBOSE_HOOKS="1" both mean noisy — no conflict.)
|
|
108
|
+
assert.equal(
|
|
109
|
+
computeQuietHooks({ env: { CODE_GRAPH_QUIET_HOOKS: '1', CODE_GRAPH_VERBOSE_HOOKS: '1' } }),
|
|
110
|
+
true
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('computeQuietHooks: env unset → quiet by default', () => {
|
|
115
|
+
assert.equal(computeQuietHooks({ env: {} }), true);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('computeQuietHooks: no args → quiet by default', () => {
|
|
119
|
+
assert.equal(computeQuietHooks(), true);
|
|
103
120
|
});
|
|
104
121
|
|
|
105
|
-
test('computeQuietHooks:
|
|
106
|
-
|
|
107
|
-
|
|
122
|
+
test('computeQuietHooks: legacy `adopted` param is ignored under new default', () => {
|
|
123
|
+
// adopted=true used to imply quiet; now quiet is unconditional.
|
|
124
|
+
// adopted=false used to imply noisy; now still quiet by default.
|
|
125
|
+
assert.equal(computeQuietHooks({ adopted: true, env: {} }), true);
|
|
126
|
+
assert.equal(computeQuietHooks({ adopted: false, env: {} }), true);
|
|
108
127
|
});
|
|
109
128
|
|
|
110
129
|
test('consistencyCheck returns version-mismatch when versions differ', (t) => {
|
|
@@ -8,13 +8,19 @@ type: reference
|
|
|
8
8
|
> Invited-memory 模式:MCP `instructions` 仅留指针,决策细则集中在此。
|
|
9
9
|
>
|
|
10
10
|
> **v0.9.0 起**:插件(`/plugin install`)模式下首次 SessionStart 自动 adopt,
|
|
11
|
-
>
|
|
11
|
+
> 本文件自动写入到项目 memory 目录。
|
|
12
12
|
> 退出:`CODE_GRAPH_NO_AUTO_ADOPT=1` 阻止,`code-graph-mcp unadopt` 回退。
|
|
13
|
-
> 手动强控:`CODE_GRAPH_QUIET_HOOKS=0` 强制注入 / `=1` 强制静默(覆盖 adoption 推导)。
|
|
14
13
|
>
|
|
15
14
|
> **v0.11.0 起**:已 adopt 的项目在下次 SessionStart 会自动对齐到插件 shipped
|
|
16
15
|
> 的最新决策表(本文件 SHA 与 template 差异时覆盖)。手动编辑会被覆盖——
|
|
17
16
|
> 要锁定自己的版本,设 `CODE_GRAPH_NO_TEMPLATE_REFRESH=1`(不影响首次 adopt)。
|
|
17
|
+
>
|
|
18
|
+
> **v0.17.0 起**:SessionStart `project_map` 注入 **默认 OFF**(不再随 adoption
|
|
19
|
+
> 切换)。本文件 + 7 个工具描述已经覆盖路由所需的全部决策信息,每次会话再
|
|
20
|
+
> dump ≈2.3 KB 的项目地图是冗余的常驻上下文成本。
|
|
21
|
+
> 显式启用:`CODE_GRAPH_VERBOSE_HOOKS=1` —— Bash 调 `code-graph-mcp map --compact`
|
|
22
|
+
> 也是等价的按需替代。
|
|
23
|
+
> 向后兼容:`CODE_GRAPH_QUIET_HOOKS=0` 强制 noisy / `=1` 强制 quiet(优先级最高)。
|
|
18
24
|
|
|
19
25
|
## 何时调用 MCP/CLI(替代多步 Grep/Read)
|
|
20
26
|
|
|
@@ -63,7 +69,7 @@ type: reference
|
|
|
63
69
|
|
|
64
70
|
## 工作流惯例
|
|
65
71
|
|
|
66
|
-
1. 起手 `project_map --compact
|
|
72
|
+
1. 起手 `project_map`(或 Bash 调 `code-graph-mcp map --compact`)看架构
|
|
67
73
|
2. `semantic_code_search` 默认带 `compact=true`,省 token
|
|
68
74
|
3. 展开节点:`get_ast_node node_id=N compact=true` 看签名 / 不带 compact 看全文
|
|
69
75
|
4. 改前评估影响:`get_ast_node symbol_name=X include_impact=true`(核心 7 内,首选)
|
|
@@ -100,7 +106,8 @@ code-graph-mcp health-check # 索引健康
|
|
|
100
106
|
|
|
101
107
|
## 卸载 / 回退
|
|
102
108
|
|
|
103
|
-
- `code-graph-mcp unadopt` — 精确移除 sentinel 段 +
|
|
109
|
+
- `code-graph-mcp unadopt` — 精确移除 sentinel 段 + 本文件。
|
|
104
110
|
- `CODE_GRAPH_NO_AUTO_ADOPT=1`(`~/.claude/settings.json` env) — 阻止未来自动 adopt,不影响已 adopted 状态。
|
|
105
111
|
- `CODE_GRAPH_NO_TEMPLATE_REFRESH=1`(v0.11.0+) — 锁定本文件不随插件升级刷新;允许手动编辑长久保留。
|
|
106
|
-
- `
|
|
112
|
+
- `CODE_GRAPH_VERBOSE_HOOKS=1`(v0.17.0+) — opt in 到 SessionStart `project_map` 注入(默认 OFF)。
|
|
113
|
+
- `CODE_GRAPH_QUIET_HOOKS=0` — 强制恢复 `project_map` 注入;优先级高于 VERBOSE_HOOKS(向后兼容路径)。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdsrs/code-graph",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.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": {
|
|
@@ -28,16 +28,17 @@
|
|
|
28
28
|
],
|
|
29
29
|
"scripts": {
|
|
30
30
|
"build": "cargo build --release --no-default-features && node scripts/copy-binary.js",
|
|
31
|
-
"prepare": "git rev-parse --git-dir > /dev/null 2>&1 && ln -sf ../../scripts/pre-commit.sh .git/hooks/pre-commit || true"
|
|
31
|
+
"prepare": "git rev-parse --git-dir > /dev/null 2>&1 && ln -sf ../../scripts/pre-commit.sh .git/hooks/pre-commit || true",
|
|
32
|
+
"preuninstall": "node claude-plugin/scripts/lifecycle.js uninstall || true"
|
|
32
33
|
},
|
|
33
34
|
"engines": {
|
|
34
35
|
"node": ">=16"
|
|
35
36
|
},
|
|
36
37
|
"optionalDependencies": {
|
|
37
|
-
"@sdsrs/code-graph-linux-x64": "0.
|
|
38
|
-
"@sdsrs/code-graph-linux-arm64": "0.
|
|
39
|
-
"@sdsrs/code-graph-darwin-x64": "0.
|
|
40
|
-
"@sdsrs/code-graph-darwin-arm64": "0.
|
|
41
|
-
"@sdsrs/code-graph-win32-x64": "0.
|
|
38
|
+
"@sdsrs/code-graph-linux-x64": "0.17.0",
|
|
39
|
+
"@sdsrs/code-graph-linux-arm64": "0.17.0",
|
|
40
|
+
"@sdsrs/code-graph-darwin-x64": "0.17.0",
|
|
41
|
+
"@sdsrs/code-graph-darwin-arm64": "0.17.0",
|
|
42
|
+
"@sdsrs/code-graph-win32-x64": "0.17.0"
|
|
42
43
|
}
|
|
43
44
|
}
|