@sdsrs/code-graph 0.7.18 → 0.8.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.
@@ -4,7 +4,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.7.18",
7
+ "version": "0.8.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // adopt / unadopt — writes plugin_code_graph_mcp.md into this project's
4
+ // claude-mem memory dir and maintains a sentinel-bracketed index entry in
5
+ // MEMORY.md. Idempotent. Used by invited-memory pattern with CODE_GRAPH_QUIET_HOOKS=1.
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const os = require('os');
9
+
10
+ const SENTINEL_BEGIN = '<!-- code-graph-mcp:begin v1 -->';
11
+ const SENTINEL_END = '<!-- code-graph-mcp:end -->';
12
+ const INDEX_LINE = '- [code-graph-mcp](plugin_code_graph_mcp.md) — "谁调 X / 改 X 炸啥 / 模块结构" → `get_call_graph` / `impact_analysis` / `module_overview`,替代多步 Grep/Read';
13
+ const TEMPLATE_PATH = path.resolve(__dirname, '..', 'templates', 'plugin_code_graph_mcp.md');
14
+ const TARGET_NAME = 'plugin_code_graph_mcp.md';
15
+
16
+ function memoryDir(cwd = process.cwd(), home = os.homedir()) {
17
+ const slug = cwd.replace(/\//g, '-');
18
+ return path.join(home, '.claude', 'projects', slug, 'memory');
19
+ }
20
+
21
+ function escapeRegex(s) {
22
+ return s.replace(/[\\/[\]^$.*+?()|{}]/g, '\\$&');
23
+ }
24
+
25
+ function adopt({ cwd, home, templatePath } = {}) {
26
+ const dir = memoryDir(cwd, home);
27
+ if (!fs.existsSync(dir)) {
28
+ return { ok: false, reason: 'no-memory-dir', dir };
29
+ }
30
+ const target = path.join(dir, TARGET_NAME);
31
+ const tpl = templatePath || TEMPLATE_PATH;
32
+ if (!fs.existsSync(tpl)) {
33
+ return { ok: false, reason: 'no-template', template: tpl };
34
+ }
35
+ fs.copyFileSync(tpl, target);
36
+
37
+ 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;
45
+ }
46
+ return { ok: true, target, indexPath, indexed };
47
+ }
48
+
49
+ function unadopt({ cwd, home } = {}) {
50
+ const dir = memoryDir(cwd, home);
51
+ const target = path.join(dir, TARGET_NAME);
52
+ const indexPath = path.join(dir, 'MEMORY.md');
53
+ let fileRemoved = false;
54
+ let indexPruned = false;
55
+
56
+ if (fs.existsSync(target)) {
57
+ fs.unlinkSync(target);
58
+ fileRemoved = true;
59
+ }
60
+ if (fs.existsSync(indexPath)) {
61
+ 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, '');
64
+ if (after !== before) {
65
+ fs.writeFileSync(indexPath, after);
66
+ indexPruned = true;
67
+ }
68
+ }
69
+ return { ok: true, fileRemoved, indexPruned, target, indexPath };
70
+ }
71
+
72
+ function formatResult(action, result) {
73
+ if (action === 'adopt') {
74
+ if (!result.ok) {
75
+ if (result.reason === 'no-memory-dir') {
76
+ return `[code-graph] Memory dir not found: ${result.dir}\n` +
77
+ ' Run \`claude\` at least once in this project to create it.';
78
+ }
79
+ if (result.reason === 'no-template') {
80
+ return `[code-graph] Template missing: ${result.template}`;
81
+ }
82
+ return `[code-graph] adopt failed: ${result.reason || 'unknown'}`;
83
+ }
84
+ 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`);
87
+ lines.push('[code-graph] Activate: set CODE_GRAPH_QUIET_HOOKS=1 in ~/.claude/settings.json env');
88
+ return lines.join('\n');
89
+ }
90
+ if (action === 'unadopt') {
91
+ const lines = [];
92
+ if (result.fileRemoved) lines.push(`[code-graph] Removed → ${result.target}`);
93
+ if (result.indexPruned) lines.push(`[code-graph] De-indexed → ${result.indexPath}`);
94
+ if (!result.fileRemoved && !result.indexPruned) lines.push('[code-graph] Nothing to unadopt');
95
+ return lines.join('\n');
96
+ }
97
+ return '';
98
+ }
99
+
100
+ if (require.main === module) {
101
+ const action = process.argv[2] === 'unadopt' ? 'unadopt' : 'adopt';
102
+ const result = action === 'unadopt' ? unadopt() : adopt();
103
+ process.stdout.write(formatResult(action, result) + '\n');
104
+ process.exit(result.ok === false ? 1 : 0);
105
+ }
106
+
107
+ module.exports = {
108
+ adopt, unadopt, memoryDir, formatResult,
109
+ SENTINEL_BEGIN, SENTINEL_END, INDEX_LINE, TEMPLATE_PATH, TARGET_NAME,
110
+ };
@@ -0,0 +1,114 @@
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, SENTINEL_BEGIN, SENTINEL_END, INDEX_LINE, TEMPLATE_PATH,
9
+ } = require('./adopt');
10
+
11
+ function makeSandbox() {
12
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-adopt-home-'));
13
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-adopt-cwd-'));
14
+ // Pre-create the memory dir (claude-mem convention — we don't create it).
15
+ const dir = memoryDir(cwd, home);
16
+ fs.mkdirSync(dir, { recursive: true });
17
+ return { home, cwd, dir, cleanup: () => {
18
+ fs.rmSync(home, { recursive: true, force: true });
19
+ fs.rmSync(cwd, { recursive: true, force: true });
20
+ }};
21
+ }
22
+
23
+ test('memoryDir slugifies cwd path', () => {
24
+ const dir = memoryDir('/home/alice/proj', '/home/alice');
25
+ assert.strictEqual(dir, '/home/alice/.claude/projects/-home-alice-proj/memory');
26
+ });
27
+
28
+ test('adopt writes template and appends sentinel block when index absent', () => {
29
+ const sb = makeSandbox();
30
+ try {
31
+ const res = adopt({ cwd: sb.cwd, home: sb.home });
32
+ assert.strictEqual(res.ok, true);
33
+ assert.strictEqual(res.indexed, true);
34
+ assert.ok(fs.existsSync(res.target), 'plugin file written');
35
+ const index = fs.readFileSync(res.indexPath, 'utf8');
36
+ assert.match(index, /^# Memory Index/);
37
+ assert.ok(index.includes(SENTINEL_BEGIN));
38
+ assert.ok(index.includes(SENTINEL_END));
39
+ assert.ok(index.includes(INDEX_LINE));
40
+ } finally { sb.cleanup(); }
41
+ });
42
+
43
+ test('adopt is idempotent — no duplicate sentinel on re-run', () => {
44
+ const sb = makeSandbox();
45
+ try {
46
+ adopt({ cwd: sb.cwd, home: sb.home });
47
+ const res2 = adopt({ cwd: sb.cwd, home: sb.home });
48
+ assert.strictEqual(res2.indexed, false, 'second run leaves index alone');
49
+ const index = fs.readFileSync(res2.indexPath, 'utf8');
50
+ const matches = index.match(new RegExp(SENTINEL_BEGIN.replace(/[\\/[\]^$.*+?()|{}]/g, '\\$&'), 'g'));
51
+ assert.strictEqual(matches.length, 1, 'sentinel appears exactly once');
52
+ } finally { sb.cleanup(); }
53
+ });
54
+
55
+ test('adopt preserves existing MEMORY.md content and appends', () => {
56
+ const sb = makeSandbox();
57
+ try {
58
+ const indexPath = path.join(sb.dir, 'MEMORY.md');
59
+ fs.writeFileSync(indexPath, '# Memory Index\n\n- [foo.md](foo.md) — existing entry\n');
60
+ adopt({ cwd: sb.cwd, home: sb.home });
61
+ const index = fs.readFileSync(indexPath, 'utf8');
62
+ assert.ok(index.includes('existing entry'), 'preserves prior entries');
63
+ assert.ok(index.includes(SENTINEL_BEGIN), 'appends sentinel');
64
+ } finally { sb.cleanup(); }
65
+ });
66
+
67
+ test('adopt fails gracefully when memory dir missing', () => {
68
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-adopt-home-'));
69
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-adopt-cwd-'));
70
+ try {
71
+ const res = adopt({ cwd, home });
72
+ assert.strictEqual(res.ok, false);
73
+ assert.strictEqual(res.reason, 'no-memory-dir');
74
+ } finally {
75
+ fs.rmSync(home, { recursive: true, force: true });
76
+ fs.rmSync(cwd, { recursive: true, force: true });
77
+ }
78
+ });
79
+
80
+ test('unadopt removes file and sentinel block, preserves other entries', () => {
81
+ const sb = makeSandbox();
82
+ try {
83
+ adopt({ cwd: sb.cwd, home: sb.home });
84
+ // add a neighboring entry
85
+ const indexPath = path.join(sb.dir, 'MEMORY.md');
86
+ const withNeighbor = fs.readFileSync(indexPath, 'utf8') + '- [bar.md](bar.md) — neighbor\n';
87
+ fs.writeFileSync(indexPath, withNeighbor);
88
+
89
+ const res = unadopt({ cwd: sb.cwd, home: sb.home });
90
+ assert.strictEqual(res.fileRemoved, true);
91
+ assert.strictEqual(res.indexPruned, true);
92
+ assert.ok(!fs.existsSync(res.target), 'plugin file gone');
93
+ const final = fs.readFileSync(indexPath, 'utf8');
94
+ assert.ok(!final.includes(SENTINEL_BEGIN), 'sentinel removed');
95
+ assert.ok(final.includes('neighbor'), 'neighbor preserved');
96
+ } finally { sb.cleanup(); }
97
+ });
98
+
99
+ test('unadopt is a no-op when never adopted', () => {
100
+ const sb = makeSandbox();
101
+ try {
102
+ const res = unadopt({ cwd: sb.cwd, home: sb.home });
103
+ assert.strictEqual(res.fileRemoved, false);
104
+ assert.strictEqual(res.indexPruned, false);
105
+ } finally { sb.cleanup(); }
106
+ });
107
+
108
+ test('template file exists and contains decision table', () => {
109
+ assert.ok(fs.existsSync(TEMPLATE_PATH), `template at ${TEMPLATE_PATH}`);
110
+ const content = fs.readFileSync(TEMPLATE_PATH, 'utf8');
111
+ assert.ok(content.includes('get_call_graph'), 'mentions get_call_graph');
112
+ assert.ok(content.includes('impact_analysis'), 'mentions impact_analysis');
113
+ assert.ok(content.includes('CODE_GRAPH_QUIET_HOOKS'), 'mentions env gate');
114
+ });
@@ -277,13 +277,17 @@ function runSessionInit() {
277
277
 
278
278
  const autoUpdateLaunched = launchBackgroundAutoUpdate();
279
279
  const indexFreshness = binaryCheck.available ? ensureIndexFresh() : 'skipped';
280
- const mapInjected = binaryCheck.available ? injectProjectMap() : false;
280
+ // CODE_GRAPH_QUIET_HOOKS=1 skip the 60-line project-map injection; rely
281
+ // on MEMORY.md pointer + on-demand `project_map` tool call instead.
282
+ const quietHooks = process.env.CODE_GRAPH_QUIET_HOOKS === '1';
283
+ const mapInjected = binaryCheck.available && !quietHooks ? injectProjectMap() : false;
281
284
  const consistencyIssues = binaryCheck.available
282
285
  ? consistencyCheck(binaryCheck.binary)
283
286
  : [];
284
287
  return {
285
288
  inactive: false, lifecycle, cacheHookHeal,
286
289
  autoUpdateLaunched, indexFreshness, mapInjected, binaryCheck, consistencyIssues,
290
+ quietHooks,
287
291
  };
288
292
  }
289
293
 
@@ -53,6 +53,10 @@ function markCooldown(type) {
53
53
  } catch { /* ok */ }
54
54
  }
55
55
 
56
+ // CODE_GRAPH_QUIET_HOOKS=1 → skip passive per-prompt injection entirely.
57
+ // Users opt in to this mode when MEMORY.md + explicit tool calls cover their needs.
58
+ if (process.env.CODE_GRAPH_QUIET_HOOKS === '1') process.exit(0);
59
+
56
60
  // --- Read user message ---
57
61
  let message;
58
62
  try {
@@ -464,3 +464,17 @@ test('skills: only expected skills exist', () => {
464
464
  const files = fs.readdirSync(skillsDir).filter(f => f.endsWith('.md')).sort();
465
465
  assert.deepEqual(files, ['explore.md', 'index.md']);
466
466
  });
467
+
468
+ test('CODE_GRAPH_QUIET_HOOKS=1 short-circuits before reading stdin', () => {
469
+ const { execFileSync } = require('node:child_process');
470
+ const script = path.join(__dirname, 'user-prompt-context.js');
471
+ const out = execFileSync(process.execPath, [script], {
472
+ input: JSON.stringify({ message: 'impact analysis for fn_that_would_trigger_search' }),
473
+ env: { ...process.env, CODE_GRAPH_QUIET_HOOKS: '1' },
474
+ encoding: 'utf8',
475
+ stdio: ['pipe', 'pipe', 'pipe'],
476
+ timeout: 2000,
477
+ });
478
+ // Quiet mode must produce no stdout — no [code-graph:*] prefix, nothing.
479
+ assert.equal(out, '');
480
+ });
@@ -0,0 +1,70 @@
1
+ ---
2
+ name: code-graph-mcp 插件契约
3
+ description: code-graph-mcp 工具调度规则 — 何时用 MCP/CLI 替代 Grep/Read,invited-memory 模式
4
+ type: reference
5
+ ---
6
+ # code-graph-mcp 插件契约
7
+
8
+ > Invited-memory 模式:MCP `instructions` 仅留指针,决策细则集中在此。
9
+ > 启用条件:`CODE_GRAPH_QUIET_HOOKS=1`(在 `~/.claude/settings.json` 的 `env` 中设置)。
10
+
11
+ ## 何时调用 MCP/CLI(替代多步 Grep/Read)
12
+
13
+ | 意图 | 工具 | 关键参数 / 例子 |
14
+ |------|------|----------------|
15
+ | "谁调用 X?" / "X 调了啥?" | `get_call_graph` / `callgraph X` | 替代 `grep "X("` |
16
+ | "改 X 会炸啥?" | `impact_analysis` / `impact X` | 修改函数签名前必跑 |
17
+ | "Y 模块长啥样?" | `module_overview` / `overview Y/` | 替代逐文件 Read |
18
+ | "找做 Z 的代码"(概念) | `semantic_code_search` / `search "Z"` | 不知道精确名 |
19
+ | "返回 T 类型的函数" | `ast_search --returns T` | 结构化筛选 |
20
+ | "X 在哪被引用?" | `find_references` / `refs X` | 含 callers/importers |
21
+ | "未使用的代码" | `find_dead_code` / `dead-code [path]` | 清理 exports |
22
+ | "相似/重复函数" | `find_similar_code` / `similar X` | 需 embedding |
23
+ | "X 文件依赖谁?" | `dependency_graph` / `deps X` | file 级别 |
24
+ | "看 X 的源码 / 签名" | `get_ast_node` / `show X` | `--include-impact` 含影响面 |
25
+ | "项目结构总览" | `project_map` / `map` | 起手势用 `--compact` |
26
+ | HTTP 路由 → handler 链路 | `trace_http_chain` / `trace ROUTE` | API 调试 |
27
+
28
+ ## 不要替代
29
+
30
+ - 精确字符串 / 常量 / 正则 → 仍用 `Grep`
31
+ - 非代码文件(README/JSON/log) → 仍用 `Grep`
32
+ - 即将编辑的具体文件 → 仍用 `Read`
33
+
34
+ ## 工作流惯例
35
+
36
+ 1. 起手 `project_map --compact` 看架构
37
+ 2. `semantic_code_search` 默认带 `compact=true`,省 token
38
+ 3. 展开节点:`get_ast_node node_id=N compact=true` 看签名 / 不带 compact 看全文
39
+ 4. 改前必跑 `impact_analysis`
40
+ 5. 搜不到结果 → `code-graph-mcp health-check` 检查索引与 embedding 覆盖率
41
+
42
+ 可用 prompts:`impact-analysis`、`understand-module`、`trace-request`
43
+
44
+ ## CLI 速查(替 Bash)
45
+
46
+ ```
47
+ code-graph-mcp grep "pattern" [path] # ripgrep + AST 上下文
48
+ code-graph-mcp search "concept" # FTS5 语义搜索
49
+ code-graph-mcp ast-search "q" --type fn # 结构化筛选
50
+ code-graph-mcp map # 项目架构
51
+ code-graph-mcp overview src/mcp/ # 模块总览
52
+ code-graph-mcp callgraph SYMBOL # 调用图
53
+ code-graph-mcp impact SYMBOL # 影响面
54
+ code-graph-mcp show SYMBOL # 节点详情
55
+ code-graph-mcp refs SYMBOL --relation calls # 引用筛选
56
+ code-graph-mcp dead-code [path] # 未使用代码
57
+ code-graph-mcp health-check # 索引健康
58
+ ```
59
+
60
+ 完整列表:`code-graph-mcp --help`。
61
+
62
+ ## 质量门槛
63
+
64
+ - `compact=true` 一般够用;要看完整代码再去掉
65
+ - `impact` 在 `--change-type signature` 时返回最严格的破坏面
66
+ - 索引陈旧 → SessionStart 自带 `ensureIndexFresh`;手动跑 `incremental-index`
67
+
68
+ ## 卸载
69
+
70
+ `code-graph-mcp unadopt` 精确移除 sentinel 段 + 本文件;或取消 `CODE_GRAPH_QUIET_HOOKS` 即恢复原注入。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/code-graph",
3
- "version": "0.7.18",
3
+ "version": "0.8.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": {
@@ -34,10 +34,10 @@
34
34
  "node": ">=16"
35
35
  },
36
36
  "optionalDependencies": {
37
- "@sdsrs/code-graph-linux-x64": "0.7.18",
38
- "@sdsrs/code-graph-linux-arm64": "0.7.18",
39
- "@sdsrs/code-graph-darwin-x64": "0.7.18",
40
- "@sdsrs/code-graph-darwin-arm64": "0.7.18",
41
- "@sdsrs/code-graph-win32-x64": "0.7.18"
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"
42
42
  }
43
43
  }