@sdsrs/code-graph 0.94.0 → 0.95.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.
Files changed (30) hide show
  1. package/claude-plugin/.claude-plugin/plugin.json +1 -1
  2. package/claude-plugin/scripts/auto-update.js +69 -9
  3. package/package.json +8 -7
  4. package/claude-plugin/scripts/adopt.test.js +0 -679
  5. package/claude-plugin/scripts/auto-update.test.js +0 -515
  6. package/claude-plugin/scripts/cg-answer.test.js +0 -309
  7. package/claude-plugin/scripts/claude-config.test.js +0 -58
  8. package/claude-plugin/scripts/covering-tests.test.js +0 -78
  9. package/claude-plugin/scripts/doctor.test.js +0 -215
  10. package/claude-plugin/scripts/find-binary.test.js +0 -246
  11. package/claude-plugin/scripts/hook-fire.test.js +0 -117
  12. package/claude-plugin/scripts/hooks.test.js +0 -230
  13. package/claude-plugin/scripts/incremental-index.test.js +0 -102
  14. package/claude-plugin/scripts/lifecycle.e2e.test.js +0 -179
  15. package/claude-plugin/scripts/lifecycle.test.js +0 -786
  16. package/claude-plugin/scripts/mcp-launcher.test.js +0 -162
  17. package/claude-plugin/scripts/mcp-stub.test.js +0 -207
  18. package/claude-plugin/scripts/post-grep-inject.test.js +0 -531
  19. package/claude-plugin/scripts/pr-impact-comment.test.js +0 -110
  20. package/claude-plugin/scripts/pre-edit-guide.test.js +0 -218
  21. package/claude-plugin/scripts/pre-grep-guide.test.js +0 -1682
  22. package/claude-plugin/scripts/pre-read-guide.test.js +0 -363
  23. package/claude-plugin/scripts/project-detect.test.js +0 -95
  24. package/claude-plugin/scripts/recommendation-log.test.js +0 -79
  25. package/claude-plugin/scripts/session-init.test.js +0 -479
  26. package/claude-plugin/scripts/statusline-composite.test.js +0 -65
  27. package/claude-plugin/scripts/statusline.test.js +0 -235
  28. package/claude-plugin/scripts/tmp-dir.test.js +0 -50
  29. package/claude-plugin/scripts/user-prompt-context.test.js +0 -743
  30. package/claude-plugin/scripts/version-utils.test.js +0 -141
@@ -1,679 +0,0 @@
1
- 'use strict';
2
- const test = require('node:test');
3
- const assert = require('node:assert');
4
- const fs = require('fs');
5
- const path = require('path');
6
- const os = require('os');
7
- const {
8
- adopt, unadopt, memoryDir, stripSentinelBlock,
9
- isAdopted, isPluginModeInstall, maybeAutoAdopt, needsRefresh, isProjectRoot,
10
- detectProjectType, buildBlock, migrateLegacyMemoryDir,
11
- SENTINEL_BEGIN, SENTINEL_END, MANAGED_BY, TEMPLATE_PATH, TARGET_NAME,
12
- PROJECT_MARKERS,
13
- } = require('./adopt');
14
-
15
- // Legacy v1 sentinel (pre-v0.74, lived in the memory-dir MEMORY.md). Hard-coded
16
- // here because the strip/migration path must keep removing it after the constant
17
- // moved to v2.
18
- const SENTINEL_BEGIN_V1 = '<!-- code-graph-mcp:begin v1 -->';
19
-
20
- function makeSandbox() {
21
- const home = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-adopt-home-'));
22
- const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-adopt-cwd-'));
23
- // Mark the sandbox cwd as a real project — adopt() gates on a project marker.
24
- fs.mkdirSync(path.join(cwd, '.git'));
25
- return {
26
- home, cwd,
27
- claudeMd: path.join(cwd, 'CLAUDE.md'),
28
- detail: path.join(cwd, '.claude', TARGET_NAME),
29
- cleanup: () => {
30
- fs.rmSync(home, { recursive: true, force: true });
31
- fs.rmSync(cwd, { recursive: true, force: true });
32
- },
33
- };
34
- }
35
-
36
- // ── memoryDir (legacy slug — still used by migrateLegacyMemoryDir) ──────────
37
-
38
- test('memoryDir slugifies cwd path', () => {
39
- assert.strictEqual(
40
- memoryDir('/home/alice/proj', '/home/alice'),
41
- '/home/alice/.claude/projects/-home-alice-proj/memory'
42
- );
43
- });
44
-
45
- test('memoryDir replaces underscores and dots (Claude Code slug convention)', () => {
46
- assert.strictEqual(
47
- memoryDir('/mnt/data_ssd/dev/projects/code-graph-mcp', '/home/u'),
48
- '/home/u/.claude/projects/-mnt-data-ssd-dev-projects-code-graph-mcp/memory'
49
- );
50
- assert.strictEqual(
51
- memoryDir('/home/sds/.claude/x', '/home/sds'),
52
- '/home/sds/.claude/projects/-home-sds--claude-x/memory'
53
- );
54
- });
55
-
56
- test('memoryDir honors CLAUDE_CONFIG_DIR override (multi-account isolation)', () => {
57
- const prev = process.env.CLAUDE_CONFIG_DIR;
58
- process.env.CLAUDE_CONFIG_DIR = '/home/alice/work-claude';
59
- try {
60
- assert.strictEqual(
61
- memoryDir('/home/alice/proj', '/home/alice'),
62
- '/home/alice/work-claude/projects/-home-alice-proj/memory'
63
- );
64
- } finally {
65
- if (prev === undefined) delete process.env.CLAUDE_CONFIG_DIR;
66
- else process.env.CLAUDE_CONFIG_DIR = prev;
67
- }
68
- });
69
-
70
- // ── buildBlock — the managed CLAUDE.md block ────────────────────────────────
71
-
72
- test('buildBlock generic: v2 sentinel + 6 base rows + pointer', () => {
73
- const block = buildBlock('generic');
74
- assert.ok(block.startsWith(SENTINEL_BEGIN), 'opens with v2 BEGIN');
75
- assert.ok(block.endsWith(SENTINEL_END), 'closes with END');
76
- assert.ok(block.includes('| Who calls X / what X calls | `code-graph-mcp callgraph X` |'));
77
- assert.ok(block.includes('| Impact before editing a fn | `code-graph-mcp impact X` |'));
78
- assert.ok(block.includes('Full command + MCP-tool table: `.claude/plugin_code_graph_mcp.md`'));
79
- assert.ok(!block.includes('trace'), 'generic has no HTTP-trace row');
80
- });
81
-
82
- test('buildBlock web-rs inserts the HTTP-route → handler row', () => {
83
- const block = buildBlock('web-rs');
84
- assert.ok(block.includes('HTTP route → handler chain'), 'web project gets trace row');
85
- assert.ok(block.includes('`code-graph-mcp trace "GET /api/x"`'));
86
- });
87
-
88
- test('buildBlock frontend surfaces a find-references audit row', () => {
89
- const block = buildBlock('frontend');
90
- assert.ok(block.includes('Rename / refactor audit (refs)'));
91
- assert.ok(block.includes('`code-graph-mcp refs X`'));
92
- });
93
-
94
- test('buildBlock is deterministic (byte-identical across calls)', () => {
95
- assert.strictEqual(buildBlock('rust'), buildBlock('rust'));
96
- assert.strictEqual(buildBlock('generic'), buildBlock(undefined));
97
- });
98
-
99
- // ── adopt — installs CLAUDE.md block + .claude/ detail ──────────────────────
100
-
101
- test('adopt creates CLAUDE.md with the block when none exists', () => {
102
- const sb = makeSandbox();
103
- try {
104
- const res = adopt({ cwd: sb.cwd });
105
- assert.strictEqual(res.ok, true);
106
- assert.strictEqual(res.created, true);
107
- assert.strictEqual(res.claudeMdWritten, true);
108
- assert.strictEqual(res.detailWritten, true);
109
- const cmd = fs.readFileSync(sb.claudeMd, 'utf8');
110
- assert.ok(cmd.includes(SENTINEL_BEGIN) && cmd.includes(SENTINEL_END));
111
- assert.ok(fs.existsSync(sb.detail), 'detail file written under .claude/');
112
- assert.ok(fs.readFileSync(sb.detail, 'utf8').startsWith(MANAGED_BY), 'detail has managed-by marker');
113
- } finally { sb.cleanup(); }
114
- });
115
-
116
- test('adopt injects the block into an existing CLAUDE.md, preserving user prose', () => {
117
- const sb = makeSandbox();
118
- try {
119
- fs.writeFileSync(sb.claudeMd, '# My Project\n\nUser instructions here.\n');
120
- const res = adopt({ cwd: sb.cwd });
121
- assert.strictEqual(res.created, false);
122
- assert.strictEqual(res.claudeMdWritten, true);
123
- const cmd = fs.readFileSync(sb.claudeMd, 'utf8');
124
- assert.ok(cmd.includes('User instructions here.'), 'preserves user prose');
125
- assert.ok(cmd.includes(SENTINEL_BEGIN), 'block appended');
126
- } finally { sb.cleanup(); }
127
- });
128
-
129
- test('adopt is idempotent — no duplicate block, no write on re-run', () => {
130
- const sb = makeSandbox();
131
- try {
132
- adopt({ cwd: sb.cwd });
133
- const res2 = adopt({ cwd: sb.cwd });
134
- assert.strictEqual(res2.claudeMdWritten, false, 'second run leaves CLAUDE.md alone');
135
- assert.strictEqual(res2.detailWritten, false, 'second run leaves detail alone');
136
- const cmd = fs.readFileSync(sb.claudeMd, 'utf8');
137
- const esc = SENTINEL_BEGIN.replace(/[\\/[\]^$.*+?()|{}]/g, '\\$&');
138
- assert.strictEqual((cmd.match(new RegExp(esc, 'g')) || []).length, 1, 'block appears exactly once');
139
- } finally { sb.cleanup(); }
140
- });
141
-
142
- test('adopt block reflects detected project type (web-rs → trace row in CLAUDE.md)', () => {
143
- const sb = makeSandbox();
144
- try {
145
- fs.writeFileSync(path.join(sb.cwd, 'Cargo.toml'), '[dependencies]\naxum = "0.7"\n');
146
- adopt({ cwd: sb.cwd });
147
- assert.ok(fs.readFileSync(sb.claudeMd, 'utf8').includes('HTTP route → handler chain'));
148
- } finally { sb.cleanup(); }
149
- });
150
-
151
- test('adopt heals a malformed prior block (orphan BEGIN) and preserves neighbors', () => {
152
- const sb = makeSandbox();
153
- try {
154
- fs.writeFileSync(sb.claudeMd,
155
- `# Project\n\nKeep me.\n\n${SENTINEL_BEGIN}\n- stale partial block\n\nAlso keep me.\n`);
156
- const res = adopt({ cwd: sb.cwd });
157
- assert.strictEqual(res.healed, true);
158
- const cmd = fs.readFileSync(sb.claudeMd, 'utf8');
159
- const esc = SENTINEL_BEGIN.replace(/[\\/[\]^$.*+?()|{}]/g, '\\$&');
160
- assert.strictEqual((cmd.match(new RegExp(esc, 'g')) || []).length, 1, 'exactly one block');
161
- assert.ok(cmd.includes('Keep me.') && cmd.includes('Also keep me.'), 'neighbors preserved');
162
- assert.ok(!cmd.includes('stale partial block'), 'stale block purged');
163
- } finally { sb.cleanup(); }
164
- });
165
-
166
- test('adopt refuses a non-project cwd and writes nothing', () => {
167
- const home = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-adopt-home-'));
168
- const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-adopt-cwd-')); // no marker
169
- try {
170
- const res = adopt({ cwd });
171
- assert.strictEqual(res.ok, false);
172
- assert.strictEqual(res.reason, 'not-a-project');
173
- assert.ok(!fs.existsSync(path.join(cwd, 'CLAUDE.md')), 'no CLAUDE.md written');
174
- assert.ok(!fs.existsSync(path.join(cwd, '.claude')), 'no .claude dir created');
175
- } finally {
176
- fs.rmSync(home, { recursive: true, force: true });
177
- fs.rmSync(cwd, { recursive: true, force: true });
178
- }
179
- });
180
-
181
- test('adopt writes atomically — no .tmp residue in cwd or .claude', () => {
182
- const sb = makeSandbox();
183
- try {
184
- adopt({ cwd: sb.cwd });
185
- const cwdResidue = fs.readdirSync(sb.cwd).filter((f) => f.includes('.tmp.'));
186
- const claudeResidue = fs.readdirSync(path.join(sb.cwd, '.claude')).filter((f) => f.includes('.tmp.'));
187
- assert.deepStrictEqual(cwdResidue, []);
188
- assert.deepStrictEqual(claudeResidue, []);
189
- } finally { sb.cleanup(); }
190
- });
191
-
192
- test('writeFileAtomic cleans its temp file when rename fails (no orphaned .tmp)', () => {
193
- const sb = makeSandbox();
194
- const realRename = fs.renameSync;
195
- try {
196
- fs.renameSync = () => { const e = new Error('EROFS: simulated read-only fs'); e.code = 'EROFS'; throw e; };
197
- try { adopt({ cwd: sb.cwd }); } catch { /* expected — rename failed */ }
198
- fs.renameSync = realRename;
199
- // .claude may or may not exist depending on which write failed first; tolerate both.
200
- const dirs = [sb.cwd, path.join(sb.cwd, '.claude')].filter((d) => fs.existsSync(d));
201
- for (const d of dirs) {
202
- assert.deepStrictEqual(fs.readdirSync(d).filter((f) => f.includes('.tmp.')), [],
203
- `failed rename must not orphan a temp in ${d}`);
204
- }
205
- } finally {
206
- fs.renameSync = realRename;
207
- sb.cleanup();
208
- }
209
- });
210
-
211
- // ── unadopt ─────────────────────────────────────────────────────────────────
212
-
213
- test('unadopt removes the block + detail file, preserving user prose', () => {
214
- const sb = makeSandbox();
215
- try {
216
- fs.writeFileSync(sb.claudeMd, '# Project\n\nMy own notes.\n');
217
- adopt({ cwd: sb.cwd });
218
- const res = unadopt({ cwd: sb.cwd, home: sb.home });
219
- assert.strictEqual(res.fileRemoved, true);
220
- assert.strictEqual(res.blockPruned, true);
221
- assert.strictEqual(res.claudeMdRemoved, false, 'CLAUDE.md kept — has user prose');
222
- assert.ok(!fs.existsSync(sb.detail), 'detail file gone');
223
- const cmd = fs.readFileSync(sb.claudeMd, 'utf8');
224
- assert.ok(!cmd.includes(SENTINEL_BEGIN), 'block removed');
225
- assert.ok(cmd.includes('My own notes.'), 'user prose preserved');
226
- } finally { sb.cleanup(); }
227
- });
228
-
229
- test('unadopt deletes a CLAUDE.md that contained only our block', () => {
230
- const sb = makeSandbox();
231
- try {
232
- adopt({ cwd: sb.cwd }); // creates a block-only CLAUDE.md
233
- const res = unadopt({ cwd: sb.cwd, home: sb.home });
234
- assert.strictEqual(res.claudeMdRemoved, true);
235
- assert.ok(!fs.existsSync(sb.claudeMd), 'block-only CLAUDE.md removed');
236
- } finally { sb.cleanup(); }
237
- });
238
-
239
- test('unadopt will NOT delete a user file lacking our managed-by marker', () => {
240
- const sb = makeSandbox();
241
- try {
242
- fs.mkdirSync(path.join(sb.cwd, '.claude'));
243
- fs.writeFileSync(sb.detail, 'user-authored notes, not ours\n');
244
- const res = unadopt({ cwd: sb.cwd, home: sb.home });
245
- assert.strictEqual(res.fileRemoved, false, 'unmarked file is not deleted');
246
- assert.ok(fs.existsSync(sb.detail), 'user file survives');
247
- } finally { sb.cleanup(); }
248
- });
249
-
250
- test('unadopt is a no-op when never adopted', () => {
251
- const sb = makeSandbox();
252
- try {
253
- const res = unadopt({ cwd: sb.cwd, home: sb.home });
254
- assert.strictEqual(res.fileRemoved, false);
255
- assert.strictEqual(res.blockPruned, false);
256
- } finally { sb.cleanup(); }
257
- });
258
-
259
- // ── isAdopted ───────────────────────────────────────────────────────────────
260
-
261
- test('isAdopted: false fresh, true after adopt, false after unadopt', () => {
262
- const sb = makeSandbox();
263
- try {
264
- assert.strictEqual(isAdopted({ cwd: sb.cwd }), false);
265
- adopt({ cwd: sb.cwd });
266
- assert.strictEqual(isAdopted({ cwd: sb.cwd }), true);
267
- unadopt({ cwd: sb.cwd, home: sb.home });
268
- assert.strictEqual(isAdopted({ cwd: sb.cwd }), false);
269
- } finally { sb.cleanup(); }
270
- });
271
-
272
- test('isAdopted: false when block present but detail file missing', () => {
273
- const sb = makeSandbox();
274
- try {
275
- fs.writeFileSync(sb.claudeMd, `${SENTINEL_BEGIN}\nx\n${SENTINEL_END}\n`);
276
- assert.strictEqual(isAdopted({ cwd: sb.cwd }), false, 'needs both block + detail');
277
- } finally { sb.cleanup(); }
278
- });
279
-
280
- // ── needsRefresh ────────────────────────────────────────────────────────────
281
-
282
- test('needsRefresh: false right after adopt', () => {
283
- const sb = makeSandbox();
284
- try {
285
- adopt({ cwd: sb.cwd });
286
- assert.strictEqual(needsRefresh({ cwd: sb.cwd }), false);
287
- } finally { sb.cleanup(); }
288
- });
289
-
290
- test('needsRefresh: true when detail-doc body drifts from shipped template', () => {
291
- const sb = makeSandbox();
292
- try {
293
- adopt({ cwd: sb.cwd });
294
- fs.writeFileSync(sb.detail, `${MANAGED_BY}\n# stale content from an older plugin\n`);
295
- assert.strictEqual(needsRefresh({ cwd: sb.cwd }), true);
296
- } finally { sb.cleanup(); }
297
- });
298
-
299
- test('needsRefresh: true when the CLAUDE.md block drifts (project type change)', () => {
300
- const sb = makeSandbox();
301
- try {
302
- adopt({ cwd: sb.cwd }); // generic block
303
- // Now the project gains a web framework — block should switch to web-rs.
304
- fs.writeFileSync(path.join(sb.cwd, 'Cargo.toml'), '[dependencies]\naxum = "0.7"\n');
305
- assert.strictEqual(needsRefresh({ cwd: sb.cwd }), true);
306
- } finally { sb.cleanup(); }
307
- });
308
-
309
- test('needsRefresh: false when not adopted (nothing to refresh)', () => {
310
- const sb = makeSandbox();
311
- try {
312
- assert.strictEqual(needsRefresh({ cwd: sb.cwd }), false);
313
- } finally { sb.cleanup(); }
314
- });
315
-
316
- // ── maybeAutoAdopt ──────────────────────────────────────────────────────────
317
-
318
- const PLUGIN_SCRIPTS = '/home/u/.claude/plugins/cache/code-graph-mcp/scripts';
319
-
320
- test('maybeAutoAdopt skips when CODE_GRAPH_NO_AUTO_ADOPT=1', () => {
321
- const sb = makeSandbox();
322
- try {
323
- const res = maybeAutoAdopt({ cwd: sb.cwd, home: sb.home, scriptPath: PLUGIN_SCRIPTS, env: { CODE_GRAPH_NO_AUTO_ADOPT: '1' } });
324
- assert.strictEqual(res.reason, 'opted-out');
325
- assert.deepStrictEqual(res.migrated, { memoryIndexPruned: false, legacyDetailRemoved: false }, 'consistent migrated shape on early return');
326
- assert.strictEqual(isAdopted({ cwd: sb.cwd }), false);
327
- } finally { sb.cleanup(); }
328
- });
329
-
330
- test('maybeAutoAdopt skips when not plugin-mode (npm install path)', () => {
331
- const sb = makeSandbox();
332
- try {
333
- const res = maybeAutoAdopt({ cwd: sb.cwd, home: sb.home, scriptPath: '/usr/local/lib/node_modules/@sdsrs/code-graph/claude-plugin/scripts', env: {} });
334
- assert.strictEqual(res.reason, 'not-plugin-mode');
335
- assert.deepStrictEqual(res.migrated, { memoryIndexPruned: false, legacyDetailRemoved: false }, 'consistent migrated shape on early return');
336
- assert.strictEqual(isAdopted({ cwd: sb.cwd }), false);
337
- } finally { sb.cleanup(); }
338
- });
339
-
340
- test('maybeAutoAdopt installs when plugin-mode + not-yet-adopted', () => {
341
- const sb = makeSandbox();
342
- try {
343
- const res = maybeAutoAdopt({ cwd: sb.cwd, home: sb.home, scriptPath: PLUGIN_SCRIPTS, env: {} });
344
- assert.strictEqual(res.attempted, true);
345
- assert.strictEqual(res.reason, 'adopted');
346
- assert.strictEqual(res.result.ok, true);
347
- assert.strictEqual(isAdopted({ cwd: sb.cwd }), true);
348
- } finally { sb.cleanup(); }
349
- });
350
-
351
- test('maybeAutoAdopt is already-adopted when in sync (no gratuitous write)', () => {
352
- const sb = makeSandbox();
353
- try {
354
- adopt({ cwd: sb.cwd });
355
- const mtime = fs.statSync(sb.claudeMd).mtimeMs;
356
- const res = maybeAutoAdopt({ cwd: sb.cwd, home: sb.home, scriptPath: PLUGIN_SCRIPTS, env: {} });
357
- assert.strictEqual(res.reason, 'already-adopted');
358
- assert.strictEqual(fs.statSync(sb.claudeMd).mtimeMs, mtime, 'CLAUDE.md not touched');
359
- } finally { sb.cleanup(); }
360
- });
361
-
362
- test('maybeAutoAdopt refreshes a drifted detail doc (reason=refreshed)', () => {
363
- const sb = makeSandbox();
364
- try {
365
- adopt({ cwd: sb.cwd });
366
- fs.writeFileSync(sb.detail, `${MANAGED_BY}\n# stale\n`);
367
- const res = maybeAutoAdopt({ cwd: sb.cwd, home: sb.home, scriptPath: PLUGIN_SCRIPTS, env: {} });
368
- assert.strictEqual(res.reason, 'refreshed');
369
- const shipped = fs.readFileSync(TEMPLATE_PATH);
370
- const cur = fs.readFileSync(sb.detail);
371
- const nl = cur.indexOf(0x0a);
372
- assert.ok(shipped.equals(cur.subarray(nl + 1)), 'detail re-synced to shipped template');
373
- } finally { sb.cleanup(); }
374
- });
375
-
376
- test('maybeAutoAdopt skips refresh when CODE_GRAPH_NO_TEMPLATE_REFRESH=1 (locks edits)', () => {
377
- const sb = makeSandbox();
378
- try {
379
- adopt({ cwd: sb.cwd });
380
- const userEdit = `${MANAGED_BY}\n# my hand-edited table\n`;
381
- fs.writeFileSync(sb.detail, userEdit);
382
- const res = maybeAutoAdopt({ cwd: sb.cwd, home: sb.home, scriptPath: PLUGIN_SCRIPTS, env: { CODE_GRAPH_NO_TEMPLATE_REFRESH: '1' } });
383
- assert.strictEqual(res.reason, 'already-adopted');
384
- assert.strictEqual(fs.readFileSync(sb.detail, 'utf8'), userEdit, 'user edit preserved');
385
- } finally { sb.cleanup(); }
386
- });
387
-
388
- test('maybeAutoAdopt surfaces not-a-project for a bare cwd', () => {
389
- const home = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-adopt-home-'));
390
- const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-adopt-cwd-'));
391
- try {
392
- const res = maybeAutoAdopt({ cwd, home, scriptPath: PLUGIN_SCRIPTS, env: {} });
393
- assert.strictEqual(res.result.ok, false);
394
- assert.strictEqual(res.result.reason, 'not-a-project');
395
- } finally {
396
- fs.rmSync(home, { recursive: true, force: true });
397
- fs.rmSync(cwd, { recursive: true, force: true });
398
- }
399
- });
400
-
401
- // ── migrateLegacyMemoryDir — auto-upgrade cleanup of the pre-v0.74 scheme ────
402
-
403
- function seedLegacy(sb) {
404
- const dir = memoryDir(sb.cwd, sb.home);
405
- fs.mkdirSync(dir, { recursive: true });
406
- fs.writeFileSync(path.join(dir, TARGET_NAME), `<!-- adopted-by: ${sb.cwd} -->\nold detail table\n`);
407
- fs.writeFileSync(path.join(dir, 'MEMORY.md'),
408
- `# Memory Index\n\n- [user_note.md](user_note.md) — keep me\n\n${SENTINEL_BEGIN_V1}\n- old code-graph router line\n${SENTINEL_END}\n`);
409
- return { dir, memIndex: path.join(dir, 'MEMORY.md'), legacyDetail: path.join(dir, TARGET_NAME) };
410
- }
411
-
412
- test('migrate strips the legacy v1 MEMORY.md block + deletes the adopted-by detail file', () => {
413
- const sb = makeSandbox();
414
- try {
415
- const L = seedLegacy(sb);
416
- const res = migrateLegacyMemoryDir({ cwd: sb.cwd, home: sb.home });
417
- assert.strictEqual(res.memoryIndexPruned, true);
418
- assert.strictEqual(res.legacyDetailRemoved, true);
419
- assert.ok(!fs.existsSync(L.legacyDetail), 'legacy detail deleted');
420
- const mem = fs.readFileSync(L.memIndex, 'utf8');
421
- assert.ok(!mem.includes(SENTINEL_BEGIN_V1), 'v1 sentinel removed');
422
- assert.ok(mem.includes('keep me'), "user's other memory preserved");
423
- } finally { sb.cleanup(); }
424
- });
425
-
426
- test('migrate will NOT delete a legacy detail file lacking the adopted-by marker', () => {
427
- const sb = makeSandbox();
428
- try {
429
- const dir = memoryDir(sb.cwd, sb.home);
430
- fs.mkdirSync(dir, { recursive: true });
431
- const userFile = path.join(dir, TARGET_NAME);
432
- fs.writeFileSync(userFile, 'a user file that happens to share the name\n');
433
- const res = migrateLegacyMemoryDir({ cwd: sb.cwd, home: sb.home });
434
- assert.strictEqual(res.legacyDetailRemoved, false);
435
- assert.ok(fs.existsSync(userFile), 'unmarked file survives');
436
- } finally { sb.cleanup(); }
437
- });
438
-
439
- test('migrate is a no-op when there is nothing to clean', () => {
440
- const sb = makeSandbox();
441
- try {
442
- const res = migrateLegacyMemoryDir({ cwd: sb.cwd, home: sb.home });
443
- assert.deepStrictEqual(res, { memoryIndexPruned: false, legacyDetailRemoved: false });
444
- } finally { sb.cleanup(); }
445
- });
446
-
447
- test('maybeAutoAdopt runs the legacy migration then installs the new scheme', () => {
448
- const sb = makeSandbox();
449
- try {
450
- const L = seedLegacy(sb);
451
- const res = maybeAutoAdopt({ cwd: sb.cwd, home: sb.home, scriptPath: PLUGIN_SCRIPTS, env: {} });
452
- assert.ok(res.migrated.memoryIndexPruned && res.migrated.legacyDetailRemoved, 'legacy cleaned');
453
- assert.ok(!fs.existsSync(L.legacyDetail), 'legacy detail gone');
454
- assert.ok(!fs.readFileSync(L.memIndex, 'utf8').includes(SENTINEL_BEGIN_V1), 'v1 block gone');
455
- assert.strictEqual(isAdopted({ cwd: sb.cwd }), true, 'new CLAUDE.md scheme installed');
456
- } finally { sb.cleanup(); }
457
- });
458
-
459
- // ── stripSentinelBlock (matches v1 + v2) ────────────────────────────────────
460
-
461
- test('stripSentinelBlock removes a well-formed v2 block, preserving neighbors', () => {
462
- const before = `# Index\nKeep.\n\n${SENTINEL_BEGIN}\nbody\n${SENTINEL_END}\n\n- [x.md](x.md)\n`;
463
- const after = stripSentinelBlock(before);
464
- assert.ok(!after.includes(SENTINEL_BEGIN) && !after.includes(SENTINEL_END));
465
- assert.ok(after.includes('Keep.') && after.includes('- [x.md](x.md)'));
466
- });
467
-
468
- test('stripSentinelBlock removes a legacy v1 block (version-agnostic match)', () => {
469
- const before = `# Index\n${SENTINEL_BEGIN_V1}\n- old line\n${SENTINEL_END}\n- [keep.md](keep.md)\n`;
470
- const after = stripSentinelBlock(before);
471
- assert.ok(!after.includes(SENTINEL_BEGIN_V1), 'v1 begin removed');
472
- assert.ok(after.includes('- [keep.md](keep.md)'), 'neighbor preserved');
473
- });
474
-
475
- test('stripSentinelBlock self-heals orphan BEGIN without END', () => {
476
- const before = `# Index\n- [a.md](a.md) — entry\n${SENTINEL_BEGIN}\nbody\n\n- [b.md](b.md) — survivor\n`;
477
- const after = stripSentinelBlock(before);
478
- assert.ok(!after.includes(SENTINEL_BEGIN), 'orphan BEGIN removed');
479
- assert.ok(after.includes('survivor') && after.includes('entry'));
480
- });
481
-
482
- test('stripSentinelBlock self-heals orphan END line', () => {
483
- const before = `# Index\n- [a.md](a.md)\n${SENTINEL_END}\n- [b.md](b.md)\n`;
484
- const after = stripSentinelBlock(before);
485
- assert.ok(!after.includes(SENTINEL_END));
486
- assert.ok(after.includes('- [a.md](a.md)') && after.includes('- [b.md](b.md)'));
487
- });
488
-
489
- // ── platform guard ──────────────────────────────────────────────────────────
490
-
491
- test('Windows platform is rejected with clear reason', { skip: process.platform === 'win32' }, () => {
492
- const orig = process.platform;
493
- Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
494
- try {
495
- const sb = makeSandbox();
496
- try {
497
- assert.strictEqual(adopt({ cwd: sb.cwd }).reason, 'windows-not-supported');
498
- assert.strictEqual(unadopt({ cwd: sb.cwd, home: sb.home }).reason, 'windows-not-supported');
499
- } finally { sb.cleanup(); }
500
- } finally {
501
- Object.defineProperty(process, 'platform', { value: orig, configurable: true });
502
- }
503
- });
504
-
505
- // ── template integrity ──────────────────────────────────────────────────────
506
-
507
- test('template file exists and contains the decision table', () => {
508
- assert.ok(fs.existsSync(TEMPLATE_PATH), `template at ${TEMPLATE_PATH}`);
509
- const content = fs.readFileSync(TEMPLATE_PATH, 'utf8');
510
- assert.ok(content.includes('get_call_graph'), 'mentions get_call_graph');
511
- assert.ok(content.includes('CODE_GRAPH_QUIET_HOOKS'), 'mentions env gate');
512
- assert.ok(content.includes('.claude/plugin_code_graph_mcp.md'), 'describes the new layout');
513
- });
514
-
515
- // ── isPluginModeInstall ─────────────────────────────────────────────────────
516
-
517
- test('isPluginModeInstall recognizes ~/.claude/plugins/... paths', () => {
518
- assert.strictEqual(isPluginModeInstall('/home/user/.claude/plugins/cache/code-graph-mcp@0.9.0/scripts'), true);
519
- });
520
-
521
- test('isPluginModeInstall rejects npm global / dev / npx paths', () => {
522
- assert.strictEqual(isPluginModeInstall('/usr/local/lib/node_modules/@sdsrs/code-graph/claude-plugin/scripts'), false);
523
- assert.strictEqual(isPluginModeInstall('/mnt/data_ssd/dev/projects/code-graph-mcp/claude-plugin/scripts'), false);
524
- assert.strictEqual(isPluginModeInstall('/tmp/npx-abc123/node_modules/@sdsrs/code-graph/claude-plugin/scripts'), false);
525
- });
526
-
527
- test('isPluginModeInstall recognizes CLAUDE_CONFIG_DIR/plugins/... paths', () => {
528
- const prev = process.env.CLAUDE_CONFIG_DIR;
529
- process.env.CLAUDE_CONFIG_DIR = '/home/alice/work-claude';
530
- try {
531
- assert.strictEqual(isPluginModeInstall('/home/alice/work-claude/plugins/cache/code-graph-mcp@0.31.0/scripts'), true);
532
- assert.strictEqual(isPluginModeInstall('/home/user/.claude/plugins/cache/code-graph-mcp/scripts'), true);
533
- assert.strictEqual(isPluginModeInstall('/home/alice/work-claude/projects/foo/memory'), false);
534
- } finally {
535
- if (prev === undefined) delete process.env.CLAUDE_CONFIG_DIR;
536
- else process.env.CLAUDE_CONFIG_DIR = prev;
537
- }
538
- });
539
-
540
- // ── isProjectRoot markers ───────────────────────────────────────────────────
541
-
542
- test('isProjectRoot detects each marker', () => {
543
- for (const marker of PROJECT_MARKERS) {
544
- const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-marker-'));
545
- try {
546
- assert.strictEqual(isProjectRoot(cwd), false, 'bare cwd should not be a project');
547
- const markerPath = path.join(cwd, marker);
548
- if (marker.startsWith('.')) fs.mkdirSync(markerPath);
549
- else fs.writeFileSync(markerPath, '');
550
- assert.strictEqual(isProjectRoot(cwd), true, `${marker} should make cwd a project`);
551
- } finally {
552
- fs.rmSync(cwd, { recursive: true, force: true });
553
- }
554
- }
555
- });
556
-
557
- // ── detectProjectType (unchanged logic; tailoring still feeds buildBlock) ────
558
-
559
- test('detectProjectType returns generic for an empty cwd', () => {
560
- const sb = makeSandbox();
561
- try { assert.strictEqual(detectProjectType(sb.cwd), 'generic'); } finally { sb.cleanup(); }
562
- });
563
-
564
- test('detectProjectType returns rust for a Cargo.toml without web framework', () => {
565
- const sb = makeSandbox();
566
- try {
567
- fs.writeFileSync(path.join(sb.cwd, 'Cargo.toml'), '[package]\nname="x"\n[dependencies]\nserde="1"\n');
568
- assert.strictEqual(detectProjectType(sb.cwd), 'rust');
569
- } finally { sb.cleanup(); }
570
- });
571
-
572
- test('detectProjectType returns web-rs when Cargo.toml has axum', () => {
573
- const sb = makeSandbox();
574
- try {
575
- fs.writeFileSync(path.join(sb.cwd, 'Cargo.toml'), '[dependencies]\naxum = "0.7"\n');
576
- assert.strictEqual(detectProjectType(sb.cwd), 'web-rs');
577
- } finally { sb.cleanup(); }
578
- });
579
-
580
- test('detectProjectType returns frontend for React/Next deps', () => {
581
- const sb = makeSandbox();
582
- try {
583
- fs.writeFileSync(path.join(sb.cwd, 'package.json'), '{"dependencies":{"next":"^14","react":"^18"}}');
584
- assert.strictEqual(detectProjectType(sb.cwd), 'frontend');
585
- } finally { sb.cleanup(); }
586
- });
587
-
588
- test('detectProjectType returns web-node for express', () => {
589
- const sb = makeSandbox();
590
- try {
591
- fs.writeFileSync(path.join(sb.cwd, 'package.json'), '{"dependencies":{"express":"^4"}}');
592
- assert.strictEqual(detectProjectType(sb.cwd), 'web-node');
593
- } finally { sb.cleanup(); }
594
- });
595
-
596
- test('detectProjectType returns web-py for FastAPI in pyproject.toml', () => {
597
- const sb = makeSandbox();
598
- try {
599
- fs.writeFileSync(path.join(sb.cwd, 'pyproject.toml'), '[tool.poetry.dependencies]\nfastapi = "^0.115"\n');
600
- assert.strictEqual(detectProjectType(sb.cwd), 'web-py');
601
- } finally { sb.cleanup(); }
602
- });
603
-
604
- test('detectProjectType ignores commented-out web-framework deps in Cargo.toml', () => {
605
- const sb = makeSandbox();
606
- try {
607
- fs.writeFileSync(path.join(sb.cwd, 'Cargo.toml'),
608
- '[package]\nname="x"\n[dependencies]\n# axum = "0.7" # disabled\nserde = "1"\n');
609
- assert.strictEqual(detectProjectType(sb.cwd), 'rust');
610
- } finally { sb.cleanup(); }
611
- });
612
-
613
- test('detectProjectType ignores axum in [dev-dependencies] only', () => {
614
- const sb = makeSandbox();
615
- try {
616
- fs.writeFileSync(path.join(sb.cwd, 'Cargo.toml'),
617
- '[package]\nname="x"\n[dependencies]\nserde = "1"\n[dev-dependencies]\naxum = "0.7"\n');
618
- assert.strictEqual(detectProjectType(sb.cwd), 'rust');
619
- } finally { sb.cleanup(); }
620
- });
621
-
622
- test('detectProjectType ignores react in devDependencies', () => {
623
- const sb = makeSandbox();
624
- try {
625
- fs.writeFileSync(path.join(sb.cwd, 'package.json'),
626
- JSON.stringify({ dependencies: { lodash: '^4' }, devDependencies: { react: '^18' } }));
627
- assert.strictEqual(detectProjectType(sb.cwd), 'node');
628
- } finally { sb.cleanup(); }
629
- });
630
-
631
- test('detectProjectType ignores // indirect deps in go.mod', () => {
632
- const sb = makeSandbox();
633
- try {
634
- fs.writeFileSync(path.join(sb.cwd, 'go.mod'),
635
- 'module example.com/x\n\nrequire (\n\tgithub.com/some/cli v1.0.0\n\tgithub.com/gin-gonic/gin v1.9.0 // indirect\n)\n');
636
- assert.strictEqual(detectProjectType(sb.cwd), 'go');
637
- } finally { sb.cleanup(); }
638
- });
639
-
640
- test('detectProjectType handles malformed package.json without throwing', () => {
641
- const sb = makeSandbox();
642
- try {
643
- fs.writeFileSync(path.join(sb.cwd, 'package.json'), '{not valid json');
644
- assert.strictEqual(detectProjectType(sb.cwd), 'node');
645
- } finally { sb.cleanup(); }
646
- });
647
-
648
- test('detectProjectType detects PEP 621 [project] dependencies block', () => {
649
- const sb = makeSandbox();
650
- try {
651
- fs.writeFileSync(path.join(sb.cwd, 'pyproject.toml'),
652
- '[project]\nname = "x"\ndependencies = ["fastapi>=0.115", "uvicorn"]\n');
653
- assert.strictEqual(detectProjectType(sb.cwd), 'web-py');
654
- } finally { sb.cleanup(); }
655
- });
656
-
657
- test('detectProjectType reads requirements.txt as fallback', () => {
658
- const sb = makeSandbox();
659
- try {
660
- fs.writeFileSync(path.join(sb.cwd, 'requirements.txt'), '# web stack\nflask>=3.0\ngunicorn\n');
661
- assert.strictEqual(detectProjectType(sb.cwd), 'web-py');
662
- } finally { sb.cleanup(); }
663
- });
664
-
665
- test('CODE_GRAPH_PROJECT_TYPE env override beats file-based detection', () => {
666
- const sb = makeSandbox();
667
- try {
668
- fs.writeFileSync(path.join(sb.cwd, 'Cargo.toml'), '[package]\nname="x"\n');
669
- assert.strictEqual(detectProjectType(sb.cwd, { CODE_GRAPH_PROJECT_TYPE: 'web-rs' }), 'web-rs');
670
- } finally { sb.cleanup(); }
671
- });
672
-
673
- test('CODE_GRAPH_PROJECT_TYPE env override falls through on invalid value', () => {
674
- const sb = makeSandbox();
675
- try {
676
- fs.writeFileSync(path.join(sb.cwd, 'Cargo.toml'), '[package]\nname="x"\n');
677
- assert.strictEqual(detectProjectType(sb.cwd, { CODE_GRAPH_PROJECT_TYPE: 'web-rust' }), 'rust');
678
- } finally { sb.cleanup(); }
679
- });