@sdsrs/code-graph 0.8.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.8.0",
7
+ "version": "0.8.2",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -13,8 +13,11 @@ const INDEX_LINE = '- [code-graph-mcp](plugin_code_graph_mcp.md) — "谁调 X /
13
13
  const TEMPLATE_PATH = path.resolve(__dirname, '..', 'templates', 'plugin_code_graph_mcp.md');
14
14
  const TARGET_NAME = 'plugin_code_graph_mcp.md';
15
15
 
16
+ // Claude Code slug convention: every non-alphanumeric-non-hyphen char → `-`.
17
+ // `/mnt/data_ssd/dev/proj` → `-mnt-data-ssd-dev-proj`
18
+ // `/home/sds/.claude/x` → `-home-sds--claude-x` (double-dash from `/.`)
16
19
  function memoryDir(cwd = process.cwd(), home = os.homedir()) {
17
- const slug = cwd.replace(/\//g, '-');
20
+ const slug = cwd.replace(/[^a-zA-Z0-9-]/g, '-');
18
21
  return path.join(home, '.claude', 'projects', slug, 'memory');
19
22
  }
20
23
 
@@ -22,7 +25,41 @@ function escapeRegex(s) {
22
25
  return s.replace(/[\\/[\]^$.*+?()|{}]/g, '\\$&');
23
26
  }
24
27
 
28
+ // Strip our sentinel block — well-formed first, then self-heal orphan begin/end.
29
+ // Shared by adopt (so re-adopt rewrites a stale/malformed block) and unadopt.
30
+ function stripSentinelBlock(text) {
31
+ const wellFormed = new RegExp(
32
+ `${escapeRegex(SENTINEL_BEGIN)}[\\s\\S]*?${escapeRegex(SENTINEL_END)}\\n?`, 'g'
33
+ );
34
+ let out = text.replace(wellFormed, '');
35
+ // Orphan BEGIN with no matching END (truncation / partial edit).
36
+ // Strip from BEGIN to the next blank line or EOF — the file is shared with
37
+ // claude-mem-lite, so we must not eat past a blank-line boundary.
38
+ if (out.includes(SENTINEL_BEGIN)) {
39
+ out = out.replace(
40
+ new RegExp(`${escapeRegex(SENTINEL_BEGIN)}[\\s\\S]*?(?=\\n\\n|$)`, 'g'),
41
+ ''
42
+ );
43
+ }
44
+ // Orphan END line by itself.
45
+ if (out.includes(SENTINEL_END)) {
46
+ out = out.split('\n').filter(l => l.trim() !== SENTINEL_END).join('\n');
47
+ }
48
+ // Collapse blank-line runs introduced by stripping mid-paragraph blocks.
49
+ return out.replace(/\n{3,}/g, '\n\n');
50
+ }
51
+
52
+ function platformGuard() {
53
+ if (process.platform === 'win32') {
54
+ return { ok: false, reason: 'windows-not-supported' };
55
+ }
56
+ return null;
57
+ }
58
+
25
59
  function adopt({ cwd, home, templatePath } = {}) {
60
+ const blocked = platformGuard();
61
+ if (blocked) return blocked;
62
+
26
63
  const dir = memoryDir(cwd, home);
27
64
  if (!fs.existsSync(dir)) {
28
65
  return { ok: false, reason: 'no-memory-dir', dir };
@@ -35,18 +72,25 @@ function adopt({ cwd, home, templatePath } = {}) {
35
72
  fs.copyFileSync(tpl, target);
36
73
 
37
74
  const indexPath = path.join(dir, 'MEMORY.md');
38
- let index = fs.existsSync(indexPath) ? fs.readFileSync(indexPath, 'utf8') : '# Memory Index\n';
39
- let indexed = false;
40
- if (!index.includes(SENTINEL_BEGIN)) {
41
- if (!index.endsWith('\n')) index += '\n';
42
- index += `${SENTINEL_BEGIN}\n${INDEX_LINE}\n${SENTINEL_END}\n`;
43
- fs.writeFileSync(indexPath, index);
44
- indexed = true;
75
+ const index = fs.existsSync(indexPath) ? fs.readFileSync(indexPath, 'utf8') : '# Memory Index\n';
76
+ const desiredBlock = `${SENTINEL_BEGIN}\n${INDEX_LINE}\n${SENTINEL_END}`;
77
+
78
+ // Already-adopted-and-well-formed: skip the write entirely.
79
+ if (index.includes(desiredBlock)) {
80
+ return { ok: true, target, indexPath, indexed: false, healed: false };
45
81
  }
46
- return { ok: true, target, indexPath, indexed };
82
+
83
+ const cleaned = stripSentinelBlock(index);
84
+ const healed = cleaned !== index;
85
+ const base = cleaned.endsWith('\n') ? cleaned : cleaned + '\n';
86
+ fs.writeFileSync(indexPath, base + desiredBlock + '\n');
87
+ return { ok: true, target, indexPath, indexed: true, healed };
47
88
  }
48
89
 
49
90
  function unadopt({ cwd, home } = {}) {
91
+ const blocked = platformGuard();
92
+ if (blocked) return blocked;
93
+
50
94
  const dir = memoryDir(cwd, home);
51
95
  const target = path.join(dir, TARGET_NAME);
52
96
  const indexPath = path.join(dir, 'MEMORY.md');
@@ -59,8 +103,7 @@ function unadopt({ cwd, home } = {}) {
59
103
  }
60
104
  if (fs.existsSync(indexPath)) {
61
105
  const before = fs.readFileSync(indexPath, 'utf8');
62
- const re = new RegExp(`${escapeRegex(SENTINEL_BEGIN)}[\\s\\S]*?${escapeRegex(SENTINEL_END)}\\n?`, 'g');
63
- const after = before.replace(re, '');
106
+ const after = stripSentinelBlock(before);
64
107
  if (after !== before) {
65
108
  fs.writeFileSync(indexPath, after);
66
109
  indexPruned = true;
@@ -70,6 +113,10 @@ function unadopt({ cwd, home } = {}) {
70
113
  }
71
114
 
72
115
  function formatResult(action, result) {
116
+ if (!result.ok && result.reason === 'windows-not-supported') {
117
+ return '[code-graph] adopt/unadopt are POSIX-only — claude-mem-lite slug ' +
118
+ 'convention on Windows is unverified. Edit MEMORY.md manually to opt in.';
119
+ }
73
120
  if (action === 'adopt') {
74
121
  if (!result.ok) {
75
122
  if (result.reason === 'no-memory-dir') {
@@ -82,8 +129,9 @@ function formatResult(action, result) {
82
129
  return `[code-graph] adopt failed: ${result.reason || 'unknown'}`;
83
130
  }
84
131
  const lines = [`[code-graph] Adopted → ${result.target}`];
85
- if (result.indexed) lines.push(`[code-graph] Indexed → ${result.indexPath}`);
86
- else lines.push(`[code-graph] Index already contains sentinel — left as-is`);
132
+ if (result.healed) lines.push(`[code-graph] Healed malformed sentinel block → ${result.indexPath}`);
133
+ else if (result.indexed) lines.push(`[code-graph] Indexed ${result.indexPath}`);
134
+ else lines.push(`[code-graph] Index already up-to-date — no write`);
87
135
  lines.push('[code-graph] Activate: set CODE_GRAPH_QUIET_HOOKS=1 in ~/.claude/settings.json env');
88
136
  return lines.join('\n');
89
137
  }
@@ -105,6 +153,6 @@ if (require.main === module) {
105
153
  }
106
154
 
107
155
  module.exports = {
108
- adopt, unadopt, memoryDir, formatResult,
156
+ adopt, unadopt, memoryDir, formatResult, stripSentinelBlock,
109
157
  SENTINEL_BEGIN, SENTINEL_END, INDEX_LINE, TEMPLATE_PATH, TARGET_NAME,
110
158
  };
@@ -5,7 +5,8 @@ const fs = require('fs');
5
5
  const path = require('path');
6
6
  const os = require('os');
7
7
  const {
8
- adopt, unadopt, memoryDir, SENTINEL_BEGIN, SENTINEL_END, INDEX_LINE, TEMPLATE_PATH,
8
+ adopt, unadopt, memoryDir, stripSentinelBlock,
9
+ SENTINEL_BEGIN, SENTINEL_END, INDEX_LINE, TEMPLATE_PATH,
9
10
  } = require('./adopt');
10
11
 
11
12
  function makeSandbox() {
@@ -25,6 +26,24 @@ test('memoryDir slugifies cwd path', () => {
25
26
  assert.strictEqual(dir, '/home/alice/.claude/projects/-home-alice-proj/memory');
26
27
  });
27
28
 
29
+ test('memoryDir replaces underscores and dots (Claude Code slug convention)', () => {
30
+ // Real-world bug: /mnt/data_ssd/... needs data-ssd slug, not data_ssd
31
+ assert.strictEqual(
32
+ memoryDir('/mnt/data_ssd/dev/projects/code-graph-mcp', '/home/u'),
33
+ '/home/u/.claude/projects/-mnt-data-ssd-dev-projects-code-graph-mcp/memory'
34
+ );
35
+ // Hidden dirs: /home/sds/.claude/x → -home-sds--claude-x (double-dash)
36
+ assert.strictEqual(
37
+ memoryDir('/home/sds/.claude/x', '/home/sds'),
38
+ '/home/sds/.claude/projects/-home-sds--claude-x/memory'
39
+ );
40
+ // Preserves case and hyphens
41
+ assert.strictEqual(
42
+ memoryDir('/Users/Alice/my-Project_v2.1', '/'),
43
+ '/.claude/projects/-Users-Alice-my-Project-v2-1/memory'
44
+ );
45
+ });
46
+
28
47
  test('adopt writes template and appends sentinel block when index absent', () => {
29
48
  const sb = makeSandbox();
30
49
  try {
@@ -112,3 +131,102 @@ test('template file exists and contains decision table', () => {
112
131
  assert.ok(content.includes('impact_analysis'), 'mentions impact_analysis');
113
132
  assert.ok(content.includes('CODE_GRAPH_QUIET_HOOKS'), 'mentions env gate');
114
133
  });
134
+
135
+ test('stripSentinelBlock removes well-formed block', () => {
136
+ const before = `# Index\n${SENTINEL_BEGIN}\n${INDEX_LINE}\n${SENTINEL_END}\n- [x.md](x.md)\n`;
137
+ const after = stripSentinelBlock(before);
138
+ assert.ok(!after.includes(SENTINEL_BEGIN));
139
+ assert.ok(!after.includes(SENTINEL_END));
140
+ assert.ok(after.includes('- [x.md](x.md)'), 'preserves neighbors');
141
+ });
142
+
143
+ test('stripSentinelBlock self-heals orphan BEGIN without END', () => {
144
+ // Truncation / partial edit scenario
145
+ const before = `# Index\n- [a.md](a.md) — entry\n${SENTINEL_BEGIN}\n${INDEX_LINE}\n\n- [b.md](b.md) — survivor\n`;
146
+ const after = stripSentinelBlock(before);
147
+ assert.ok(!after.includes(SENTINEL_BEGIN), 'orphan BEGIN removed');
148
+ assert.ok(after.includes('survivor'), 'content past blank-line boundary preserved');
149
+ assert.ok(after.includes('entry'), 'content before BEGIN preserved');
150
+ });
151
+
152
+ test('stripSentinelBlock self-heals orphan END line', () => {
153
+ const before = `# Index\n- [a.md](a.md)\n${SENTINEL_END}\n- [b.md](b.md)\n`;
154
+ const after = stripSentinelBlock(before);
155
+ assert.ok(!after.includes(SENTINEL_END));
156
+ assert.ok(after.includes('- [a.md](a.md)') && after.includes('- [b.md](b.md)'));
157
+ });
158
+
159
+ test('adopt heals malformed sentinel (orphan BEGIN) on re-run', () => {
160
+ const sb = makeSandbox();
161
+ try {
162
+ const indexPath = path.join(sb.dir, 'MEMORY.md');
163
+ // Simulate truncated prior adopt — BEGIN line + stale entry, no END
164
+ fs.writeFileSync(
165
+ indexPath,
166
+ `# Memory Index\n- [old.md](old.md) — preserved\n${SENTINEL_BEGIN}\n- [stale](stale.md) — wrong entry\n\n- [neighbor.md](neighbor.md) — survives\n`
167
+ );
168
+ const res = adopt({ cwd: sb.cwd, home: sb.home });
169
+ assert.strictEqual(res.ok, true);
170
+ assert.strictEqual(res.healed, true, 'reports healed');
171
+ const final = fs.readFileSync(indexPath, 'utf8');
172
+ // Exactly one well-formed block now
173
+ const beginCount = (final.match(new RegExp(SENTINEL_BEGIN.replace(/[\\/[\]^$.*+?()|{}]/g, '\\$&'), 'g')) || []).length;
174
+ const endCount = (final.match(new RegExp(SENTINEL_END.replace(/[\\/[\]^$.*+?()|{}]/g, '\\$&'), 'g')) || []).length;
175
+ assert.strictEqual(beginCount, 1, 'one BEGIN');
176
+ assert.strictEqual(endCount, 1, 'one END');
177
+ assert.ok(final.includes('preserved'), 'preserves pre-BEGIN content');
178
+ assert.ok(final.includes('neighbor.md'), 'preserves post-malformed-block content');
179
+ assert.ok(!final.includes('stale.md'), 'old wrong entry purged');
180
+ assert.ok(final.includes(INDEX_LINE), 'fresh canonical line written');
181
+ } finally { sb.cleanup(); }
182
+ });
183
+
184
+ test('adopt is a true no-op when desired block is already present verbatim', () => {
185
+ const sb = makeSandbox();
186
+ try {
187
+ adopt({ cwd: sb.cwd, home: sb.home });
188
+ const indexPath = path.join(sb.dir, 'MEMORY.md');
189
+ const before = fs.readFileSync(indexPath, 'utf8');
190
+ const beforeMtime = fs.statSync(indexPath).mtimeMs;
191
+ const res2 = adopt({ cwd: sb.cwd, home: sb.home });
192
+ assert.strictEqual(res2.indexed, false);
193
+ assert.strictEqual(res2.healed, false);
194
+ assert.strictEqual(fs.readFileSync(indexPath, 'utf8'), before, 'file content identical');
195
+ // mtime may equal beforeMtime since we skipped the write
196
+ assert.strictEqual(fs.statSync(indexPath).mtimeMs, beforeMtime, 'no write occurred');
197
+ } finally { sb.cleanup(); }
198
+ });
199
+
200
+ test('unadopt heals malformed sentinel (orphan BEGIN)', () => {
201
+ const sb = makeSandbox();
202
+ try {
203
+ const indexPath = path.join(sb.dir, 'MEMORY.md');
204
+ fs.writeFileSync(
205
+ indexPath,
206
+ `# Index\n${SENTINEL_BEGIN}\n${INDEX_LINE}\n\n- [keep.md](keep.md) — survives\n`
207
+ );
208
+ const res = unadopt({ cwd: sb.cwd, home: sb.home });
209
+ assert.strictEqual(res.indexPruned, true);
210
+ const final = fs.readFileSync(indexPath, 'utf8');
211
+ assert.ok(!final.includes(SENTINEL_BEGIN), 'orphan BEGIN purged');
212
+ assert.ok(final.includes('keep.md'), 'content past blank-line preserved');
213
+ } finally { sb.cleanup(); }
214
+ });
215
+
216
+ test('Windows platform is rejected with clear reason', { skip: process.platform === 'win32' }, () => {
217
+ const orig = process.platform;
218
+ Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
219
+ try {
220
+ const sb = makeSandbox();
221
+ try {
222
+ const adoptRes = adopt({ cwd: sb.cwd, home: sb.home });
223
+ assert.strictEqual(adoptRes.ok, false);
224
+ assert.strictEqual(adoptRes.reason, 'windows-not-supported');
225
+ const unadoptRes = unadopt({ cwd: sb.cwd, home: sb.home });
226
+ assert.strictEqual(unadoptRes.ok, false);
227
+ assert.strictEqual(unadoptRes.reason, 'windows-not-supported');
228
+ } finally { sb.cleanup(); }
229
+ } finally {
230
+ Object.defineProperty(process, 'platform', { value: orig, configurable: true });
231
+ }
232
+ });
@@ -465,16 +465,17 @@ test('skills: only expected skills exist', () => {
465
465
  assert.deepEqual(files, ['explore.md', 'index.md']);
466
466
  });
467
467
 
468
- test('CODE_GRAPH_QUIET_HOOKS=1 short-circuits before reading stdin', () => {
469
- const { execFileSync } = require('node:child_process');
468
+ test('CODE_GRAPH_QUIET_HOOKS=1 short-circuits silently on stdout, stderr, exit 0', () => {
469
+ const { spawnSync } = require('node:child_process');
470
470
  const script = path.join(__dirname, 'user-prompt-context.js');
471
- const out = execFileSync(process.execPath, [script], {
471
+ const proc = spawnSync(process.execPath, [script], {
472
472
  input: JSON.stringify({ message: 'impact analysis for fn_that_would_trigger_search' }),
473
473
  env: { ...process.env, CODE_GRAPH_QUIET_HOOKS: '1' },
474
474
  encoding: 'utf8',
475
- stdio: ['pipe', 'pipe', 'pipe'],
476
475
  timeout: 2000,
477
476
  });
478
- // Quiet mode must produce no stdoutno [code-graph:*] prefix, nothing.
479
- assert.equal(out, '');
477
+ // Quiet mode must be fully silentany stderr leaks into Claude's display.
478
+ assert.equal(proc.stdout, '', 'stdout must be empty');
479
+ assert.equal(proc.stderr, '', 'stderr must be empty');
480
+ assert.equal(proc.status, 0, 'must exit 0');
480
481
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/code-graph",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
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": {
@@ -34,10 +34,10 @@
34
34
  "node": ">=16"
35
35
  },
36
36
  "optionalDependencies": {
37
- "@sdsrs/code-graph-linux-x64": "0.8.0",
38
- "@sdsrs/code-graph-linux-arm64": "0.8.0",
39
- "@sdsrs/code-graph-darwin-x64": "0.8.0",
40
- "@sdsrs/code-graph-darwin-arm64": "0.8.0",
41
- "@sdsrs/code-graph-win32-x64": "0.8.0"
37
+ "@sdsrs/code-graph-linux-x64": "0.8.2",
38
+ "@sdsrs/code-graph-linux-arm64": "0.8.2",
39
+ "@sdsrs/code-graph-darwin-x64": "0.8.2",
40
+ "@sdsrs/code-graph-darwin-arm64": "0.8.2",
41
+ "@sdsrs/code-graph-win32-x64": "0.8.2"
42
42
  }
43
43
  }