@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.
|
@@ -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(
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
86
|
-
else lines.push(`[code-graph]
|
|
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,
|
|
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
|
|
469
|
-
const {
|
|
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
|
|
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
|
|
479
|
-
assert.equal(
|
|
477
|
+
// Quiet mode must be fully silent — any 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.
|
|
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.
|
|
38
|
-
"@sdsrs/code-graph-linux-arm64": "0.8.
|
|
39
|
-
"@sdsrs/code-graph-darwin-x64": "0.8.
|
|
40
|
-
"@sdsrs/code-graph-darwin-arm64": "0.8.
|
|
41
|
-
"@sdsrs/code-graph-win32-x64": "0.8.
|
|
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
|
}
|