diff --git a/CLAUDE.md b/CLAUDE.md
index db516d9..393f9cc 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -61,17 +61,18 @@ Code graph tools are available via MCP. The MCP server injects `instructions` at
### Last Session
-Request: 配置提交代码时的git pre-commit hook检查和代码审核工具,避免下次github上再出现错误。 Pre-commit hook (scripts/pre-commit.sh): ┌──────────┬────…
-Completed: Modified package.json, .gitignore; Modified pre-commit.sh
-
-### File Lessons
-- cli.rs: Embedding availability must be validated before semantic search to prevent runtime errors; distingu… (#5321)
+Request: Simulate user-level testing of all code-graph-mcp functions and UX, fix discovered problems, evaluate programming effic…
+Completed: Fixed tools.rs compilation (Phase 3 result-building); Modified pipeline.rs (default resolution logic); Created SKILL.md…
+Remaining: Comprehensive UX testing not executed; Loop plugin 3-iteration execution not performed; Functional testing of all code-…
+Next: 1) Execute user-level functional testing workflow via loop plugin (3× as specified); 2) Document UX findings and issues…
+Lessons: Phase 3 result struct initialization in tools.rs requires explicit type handling; Multi-file code pattern searches needed to identify incomplete reference mapping implementations
+Decisions: Prioritized compilation correctness (tools.rs, pipeline.rs) before comprehensive UX testing; Created SKILL.md to improve project discoverability and functionality documentation
### Key Context
-- [bugfix] Error: queries.rs: Error: Usage: code-graph-mcp search --jso… (#5267)
-- [bugfix] Error: session-init.test.js: TAP version 13 # Subtest: syncLifecycleConfig is … (#5202)
-- [bugfix] Error: user-prompt-context.js, session-init.js, mcp-launcher.js: TAP version 13… (#5200)
-- [bugfix] Error: cli.rs: struct Database src/storage/db.rs:30-33 fn McpSe… (#5096)
-- [bugfix] Error: cli.rs: code-graph Symbol not found: --- code-graph No r… (#5079)
+- [discovery] Reviewed 2 files: treesitter.rs, relations.rs (#5740)
+- [refactor] Remove unused thread import from watcher.rs (#5714)
+- [bugfix] Error: tools.rs: Compiling code-graph-mcp v0.7.14 (/mnt/data_ssd/d… (#5701)
+- [bugfix] Error: tools.rs: error: Your local changes to the following files … (#5697)
+- [change] Add idempotent column insertion checks to schema.rs (#5696)
diff --git a/Cargo.lock b/Cargo.lock
index 97f3c28..751bef5 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -339,7 +339,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "code-graph-mcp"
-version = "0.7.13"
+version = "0.7.14"
dependencies = [
"anyhow",
"blake3",
diff --git a/claude-plugin/commands/impact.md b/claude-plugin/commands/impact.md
deleted file mode 100644
index fd22d3a..0000000
--- a/claude-plugin/commands/impact.md
+++ /dev/null
@@ -1,9 +0,0 @@
----
-description: Analyze blast radius before modifying a symbol. Use when about to edit/rename/remove a function, or asked about change risk and affected callers.
-argument-hint:
----
-
-## Impact Analysis
-!`code-graph-mcp impact $ARGUMENTS 2>/dev/null || echo "Symbol not found or no index. Run: code-graph-mcp incremental-index"`
-
-Present the risk assessment and recommend whether it's safe to proceed.
diff --git a/claude-plugin/commands/rebuild.md b/claude-plugin/commands/rebuild.md
deleted file mode 100644
index 1dff46c..0000000
--- a/claude-plugin/commands/rebuild.md
+++ /dev/null
@@ -1,7 +0,0 @@
----
-description: Force code-graph index rebuild. Use when search results seem stale or wrong, after major codebase restructuring, or when index health check reports issues.
----
-
-Run via Bash: `code-graph-mcp incremental-index`
-This updates the index incrementally (only changed files).
-For a full rebuild, delete `.code-graph/` first, then run the MCP server.
diff --git a/claude-plugin/commands/status.md b/claude-plugin/commands/status.md
deleted file mode 100644
index eb3aea3..0000000
--- a/claude-plugin/commands/status.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-description: Show code-graph index health and coverage. Use when search returns unexpected results, checking if index is current, or diagnosing code-graph issues.
----
-
-!`code-graph-mcp health-check --format json 2>/dev/null || echo '{"error":"No index found"}'`
diff --git a/claude-plugin/commands/trace.md b/claude-plugin/commands/trace.md
deleted file mode 100644
index 61a8fc6..0000000
--- a/claude-plugin/commands/trace.md
+++ /dev/null
@@ -1,12 +0,0 @@
----
-description: Trace call flow from a handler or route. Use when debugging API behavior, understanding request processing flow, or asked how an endpoint works.
-argument-hint:
----
-
-## Call Graph (callees)
-!`code-graph-mcp callgraph $ARGUMENTS --direction callees --depth 4 2>/dev/null || echo "Symbol not found or no index."`
-
-## Call Graph (callers)
-!`code-graph-mcp callgraph $ARGUMENTS --direction callers --depth 2 2>/dev/null`
-
-Map the flow and highlight error handling, auth checks, and data access points.
diff --git a/claude-plugin/commands/understand.md b/claude-plugin/commands/understand.md
deleted file mode 100644
index dc83b7a..0000000
--- a/claude-plugin/commands/understand.md
+++ /dev/null
@@ -1,12 +0,0 @@
----
-description: Deep dive into a module's architecture. Use when starting work in an unfamiliar area, asked to explain how code works, or before implementing changes in a module.
-argument-hint:
----
-
-## Module Overview
-!`code-graph-mcp overview $ARGUMENTS 2>/dev/null || echo "No index or no symbols found. Run: code-graph-mcp incremental-index"`
-
-## Call Graph (top symbols)
-!`code-graph-mcp search "$ARGUMENTS" --limit 5 2>/dev/null`
-
-Analyze the above and summarize: purpose, public API, key internal helpers, and hot paths.
diff --git a/claude-plugin/scripts/pre-edit-guide.js b/claude-plugin/scripts/pre-edit-guide.js
index 844c763..76dffe0 100644
--- a/claude-plugin/scripts/pre-edit-guide.js
+++ b/claude-plugin/scripts/pre-edit-guide.js
@@ -89,10 +89,14 @@ if (!symbol || symbol.length < 3) {
if (!symbol || symbol.length < 3) process.exit(0);
// Skip common patterns that aren't real function names
-if (/^(if|for|while|switch|catch|else|return|new|get|set|try)$/i.test(symbol)) {
+if (isCommonKeyword(symbol)) {
process.exit(0);
}
+function isCommonKeyword(s) {
+ return /^(if|for|while|switch|catch|else|return|new|get|set|try)$/i.test(s);
+}
+
// --- Per-symbol cooldown: 2 minutes ---
const cooldownFile = path.join(os.tmpdir(), `.cg-impact-${symbol}`);
try {
diff --git a/claude-plugin/scripts/pre-edit-guide.test.js b/claude-plugin/scripts/pre-edit-guide.test.js
new file mode 100644
index 0000000..04c0d4b
--- /dev/null
+++ b/claude-plugin/scripts/pre-edit-guide.test.js
@@ -0,0 +1,161 @@
+'use strict';
+const test = require('node:test');
+const assert = require('node:assert/strict');
+
+// Pre-edit-guide.js is a script with side effects (reads stdin, checks db).
+// We test its PATTERNS directly without requiring the module.
+
+// --- Function signature patterns (copied from pre-edit-guide.js) ---
+const fnPatterns = [
+ /(?:pub\s+)?(?:async\s+)?fn\s+(\w+)/, // Rust
+ /(?:export\s+)?(?:async\s+)?function\s+(\w+)/, // JS/TS
+ /(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?(?:\([^)]*\)|_)\s*=>/, // JS arrow
+ /(?:async\s+)?(\w+)\s*\([^)]*\)\s*\{/, // JS method / Go func
+ /def\s+(\w+)/, // Python/Ruby
+ /func\s+(\w+)/, // Go/Swift
+ /(?:public|private|protected|static|override|virtual|abstract|internal)\s+\S+\s+(\w+)\s*\(/, // Java/C#/Kotlin
+ /(?:public\s+)?function\s+(\w+)/, // PHP
+];
+
+function extractFunctionName(code) {
+ for (const pat of fnPatterns) {
+ const m = code.match(pat);
+ if (m) return m[1] || m[2];
+ }
+ return null;
+}
+
+function isCommonKeyword(s) {
+ return /^(if|for|while|switch|catch|else|return|new|get|set|try)$/i.test(s);
+}
+
+// ── Rust ────────────────────────────────────────────────
+
+test('fn-extract: Rust pub fn', () => {
+ assert.equal(extractFunctionName('pub fn parse_code(input: &str) -> Vec {'), 'parse_code');
+});
+
+test('fn-extract: Rust pub async fn', () => {
+ assert.equal(extractFunctionName('pub async fn handle_message(&self, msg: &str) -> Result<()> {'), 'handle_message');
+});
+
+test('fn-extract: Rust fn (no pub)', () => {
+ assert.equal(extractFunctionName('fn helper_func(x: i32) -> i32 {'), 'helper_func');
+});
+
+// ── JavaScript/TypeScript ───────────────────────────────
+
+test('fn-extract: JS function', () => {
+ assert.equal(extractFunctionName('function handleRequest(req, res) {'), 'handleRequest');
+});
+
+test('fn-extract: JS export function', () => {
+ assert.equal(extractFunctionName('export function processData(input) {'), 'processData');
+});
+
+test('fn-extract: JS async function', () => {
+ assert.equal(extractFunctionName('async function fetchData(url) {'), 'fetchData');
+});
+
+test('fn-extract: JS export async function', () => {
+ assert.equal(extractFunctionName('export async function loadConfig(path) {'), 'loadConfig');
+});
+
+test('fn-extract: JS arrow function (const)', () => {
+ assert.equal(extractFunctionName('const handleError = (err) => {'), 'handleError');
+});
+
+test('fn-extract: JS arrow function (async)', () => {
+ assert.equal(extractFunctionName('const fetchUser = async (id) => {'), 'fetchUser');
+});
+
+test('fn-extract: JS method', () => {
+ assert.equal(extractFunctionName(' handleMessage(msg) {'), 'handleMessage');
+});
+
+// ── Python ──────────────────────────────────────────────
+
+test('fn-extract: Python def', () => {
+ assert.equal(extractFunctionName('def process_data(self, items):'), 'process_data');
+});
+
+test('fn-extract: Python async def', () => {
+ assert.equal(extractFunctionName('async def fetch_data(url):'), 'fetch_data');
+});
+
+// ── Go ──────────────────────────────────────────────────
+
+test('fn-extract: Go func', () => {
+ assert.equal(extractFunctionName('func HandleRequest(w http.ResponseWriter, r *http.Request) {'), 'HandleRequest');
+});
+
+// ── Java/C#/Kotlin ──────────────────────────────────────
+
+test('fn-extract: Java public method', () => {
+ assert.equal(extractFunctionName('public void processItem(Item item) {'), 'processItem');
+});
+
+test('fn-extract: Java private method', () => {
+ assert.equal(extractFunctionName('private String formatOutput(Data data) {'), 'formatOutput');
+});
+
+test('fn-extract: C# static method', () => {
+ assert.equal(extractFunctionName('static int CalculateTotal(List items) {'), 'CalculateTotal');
+});
+
+// ── PHP ─────────────────────────────────────────────────
+
+test('fn-extract: PHP function', () => {
+ assert.equal(extractFunctionName('function handleUpload($file) {'), 'handleUpload');
+});
+
+test('fn-extract: PHP public function', () => {
+ assert.equal(extractFunctionName('public function getUser($id) {'), 'getUser');
+});
+
+// ── Ruby ────────────────────────────────────────────────
+
+test('fn-extract: Ruby def', () => {
+ assert.equal(extractFunctionName('def process_request(params)'), 'process_request');
+});
+
+// ── Keyword filter ──────────────────────────────────────
+
+test('keyword-filter: common keywords rejected', () => {
+ for (const kw of ['if', 'for', 'while', 'switch', 'catch', 'else', 'return', 'new', 'get', 'set', 'try']) {
+ assert.ok(isCommonKeyword(kw), `"${kw}" should be rejected`);
+ }
+});
+
+test('keyword-filter: real function names pass', () => {
+ for (const name of ['parse_code', 'handleMessage', 'process_data', 'fetchUser']) {
+ assert.ok(!isCommonKeyword(name), `"${name}" should pass`);
+ }
+});
+
+// ── No false positives ──────────────────────────────────
+
+test('fn-extract: plain code body returns null', () => {
+ assert.equal(extractFunctionName('let x = 42;\nreturn x + 1;'), null);
+});
+
+test('fn-extract: comment returns null', () => {
+ assert.equal(extractFunctionName('// This is a comment about the function'), null);
+});
+
+test('fn-extract: short strings return null', () => {
+ assert.equal(extractFunctionName('x = 1'), null);
+});
+
+// ── Pattern consistency check ───────────────────────────
+// Verify fnPatterns in this test match what's in pre-edit-guide.js
+
+test('pattern-sync: fnPatterns count matches source', () => {
+ const fs = require('node:fs');
+ const path = require('node:path');
+ const source = fs.readFileSync(path.join(__dirname, 'pre-edit-guide.js'), 'utf8');
+ // Count regex pattern lines in the fnPatterns array (lines containing // Language comment)
+ const sourcePatternCount = (source.match(/\/\/\s*(Rust|JS|Python|Go|Java|C#|PHP|Ruby|Swift|Kotlin)/g) || []).length;
+ assert.ok(fnPatterns.length === 8, `Expected 8 patterns, got ${fnPatterns.length}`);
+ assert.ok(sourcePatternCount >= 7, `Source should have >= 7 language comments, found ${sourcePatternCount}`);
+});
diff --git a/claude-plugin/scripts/user-prompt-context.js b/claude-plugin/scripts/user-prompt-context.js
index 5b1b246..233ea65 100644
--- a/claude-plugin/scripts/user-prompt-context.js
+++ b/claude-plugin/scripts/user-prompt-context.js
@@ -69,7 +69,7 @@ const cwd = process.cwd();
const dbPath = path.join(cwd, '.code-graph', 'index.db');
if (!fs.existsSync(dbPath)) process.exit(0);
-// --- Constants ---
+// --- Pure logic (exported for testing) ---
const STOP_WORDS = new Set([
'this', 'that', 'with', 'from', 'what', 'when', 'which', 'there',
@@ -79,100 +79,109 @@ const STOP_WORDS = new Set([
'being', 'through', 'default', 'function', 'method', 'class',
]);
-// --- Detect intent + entities ---
+const PLAIN_WORD_EXCLUDE = /^(possible|together|actually|something|different|important|following|available|necessary|currently|implement|operation|otherwise|beginning|knowledge|attention|according|certainly|sometimes|direction|recommend|structure|describe|question|complete|generate|anything|continue|consider|response|approach|happened|recently|probably|expected|previous|original|specific|directly|received|required|supposed|separate|designed|finished|provided|included|prepared|combined|properly|remember|whatever|although|document|handling|existing|everyone|standard|research|personal|relative|absolute|practice|language|thousand|national|evidence|refactor|understand|validate|analysis|debugging|configure|improving|resolving|creating|building|checking|updating|removing|changing|searching|cleaning|optimize|migration|overview|introduce|reviewing|thinking|managing|starting|yourself|features|problems|breaking|requires|argument|settings|includes|examples|comments|patterns|tutorial|concepts|supports|priority|organize|scenario|tracking|internal|external|abstract|concrete|strategy|evaluate|diagnose|platform|variable|optional|multiple)$/;
-// Skip non-code prompts (commit, push, simple confirmations, chat, instructions, etc.)
-const trimmed = message.trim();
-if (/^(yes|no|ok|commit|push|y|n|done|thanks|thank you|继续|确认|好的|好|是的|不|可以|行|对|提交|推送|没问题|谢谢|发布|更新|编译|安装|卸载|重启|重连|清理)\s*[.!?。!?]?\s*$/i.test(trimmed)) {
- process.exit(0);
+function shouldSkip(msg) {
+ const trimmed = msg.trim();
+ if (/^(yes|no|ok|commit|push|y|n|done|thanks|thank you|继续|确认|好的|好|是的|不|可以|行|对|提交|推送|没问题|谢谢|发布|更新|编译|安装|卸载|重启|重连|清理)\s*[.!?。!?]?\s*$/i.test(trimmed)) return 'simple';
+ if (/^(修复|实施|执行|开始|按|实测|进入|用|重新)/.test(trimmed) && !/[a-zA-Z_]{3,}/.test(trimmed)) return 'action-only';
+ return false;
}
-// Skip action-only prompts without code entities (修复这些问题, 按优先级实施, etc.)
-if (/^(修复|优化|实施|执行|开始|按|实测|帮我|进入|用|重新)/.test(trimmed) && !/[a-zA-Z_]{4,}/.test(trimmed)) {
- process.exit(0);
+
+function extractFilePaths(msg) {
+ return (msg.match(/(?:src|lib|test|pkg|cmd|internal|app|components?)\/[\w/.-]+/g) || []).slice(0, 2);
}
-// Extract file paths from message
-const filePaths = (message.match(/(?:src|lib|test|pkg|cmd|internal|app|components?)\/[\w/.-]+/g) || [])
- .slice(0, 2);
-
-// Extract potential symbol names (camelCase, snake_case, PascalCase, qualified like Foo::bar, Foo.bar, Foo::bar::baz)
-const symbolCandidates = (message.match(/\b(?:[A-Z]\w*(?:(?:::|\.)\w+)+|[a-z]\w*(?:_\w+){1,}|[a-z]\w*(?:[A-Z]\w*)+|[A-Z][a-z]+(?:[A-Z][a-z]+)+)\b/g) || [])
- .filter(s => s.length > 4)
- .filter(s => !STOP_WORDS.has(s.toLowerCase()))
- .slice(0, 3);
-
-// Fallback: extract backtick-quoted symbols (common in mixed Chinese+code: "修改 `parse_code` 函数")
-if (symbolCandidates.length === 0) {
- const backtickSymbols = (message.match(/`([a-zA-Z_]\w{2,})`/g) || [])
- .map(s => s.replace(/`/g, ''))
- .filter(s => s.length >= 3 && !STOP_WORDS.has(s.toLowerCase()));
- symbolCandidates.push(...backtickSymbols.slice(0, 3));
+function extractSymbols(msg) {
+ const candidates = (msg.match(/\b(?:[A-Z]\w*(?:(?:::|\.)\w+)+|[a-z]\w*(?:_\w+){1,}|[a-z]\w*(?:[A-Z]\w*)+|[A-Z][a-z]+(?:[A-Z][a-z]+)+)\b/g) || [])
+ .filter(s => s.length > 4)
+ .filter(s => !STOP_WORDS.has(s.toLowerCase()))
+ .slice(0, 3);
+
+ if (candidates.length === 0) {
+ const backtickSymbols = (msg.match(/`([a-zA-Z_]\w{2,})`/g) || [])
+ .map(s => s.replace(/`/g, ''))
+ .filter(s => s.length >= 3 && !STOP_WORDS.has(s.toLowerCase()));
+ candidates.push(...backtickSymbols.slice(0, 3));
+ }
+
+ let lowConfidence = false;
+ if (candidates.length === 0) {
+ const plain = (msg.match(/\b[a-z][a-z]{7,}\b/g) || [])
+ .filter(s => !STOP_WORDS.has(s))
+ .filter(s => !PLAIN_WORD_EXCLUDE.test(s));
+ candidates.push(...plain.slice(0, 2));
+ if (candidates.length > 0) lowConfidence = true;
+ }
+
+ return { symbols: candidates, lowConfidence };
}
-// Fallback: plain lowercase words (8+ chars) likely to be function/type names.
-// Only when strict patterns found nothing — avoids false positives from English prose.
-// Minimum 8 chars filters most common English words while keeping technical terms
-// (authenticate, serialize, initialize, dispatch, resolver, etc.)
-if (symbolCandidates.length === 0) {
- const plain = (message.match(/\b[a-z][a-z]{7,}\b/g) || [])
- .filter(s => !STOP_WORDS.has(s))
- .filter(s => !/^(possible|together|actually|something|different|important|following|available|necessary|currently|implement|operation|otherwise|beginning|knowledge|attention|according|certainly|sometimes|direction|recommend|structure|describe|question|complete|generate|anything|continue|consider|response|approach|happened|recently|probably|expected|previous|original|specific|directly|received|required|supposed|separate|designed|finished|provided|included|prepared|combined|properly|remember|whatever|although|document|handling|existing|everyone|standard|research|personal|relative|absolute|practice|language|thousand|national|evidence)$/.test(s));
- symbolCandidates.push(...plain.slice(0, 2));
+function detectIntents(msg) {
+ return {
+ impact: /(?:impact|影响|修改前|改之前|blast radius|before (?:edit|chang|modif)|risk|风险|改动范围|波及|问题在|bug|干扰|冲突|卡)/i.test(msg),
+ modify: /(?:改(?!变)|修改|修复|重构|优化|简化|精简|适配|统一|修正|调整|去掉|整理|清理|解耦|更新|\brefactor\b|\bchange\b|\brename\b|\bfix\b|移动|\bmove\b|删(?!除文件)|\bremove\b|替换|\breplace\b|\bupdate\b|升级|\bmigrate\b|迁移|拆分|\bsplit\b|合并|\bmerge\b|提取|\bextract\b|改成|改为|换成|转为|异步|同步)/i.test(msg),
+ implement: /(?:\badd\b|\bimplement\b|\bcreate\b|\bbuild\b|\bwrite\b|新增|添加|实现|创建|编写|开发|增加|加上|加个|写|做个|搭建|补充|引入|支持|封装|接入|对接|配置)/i.test(msg),
+ understand: /(?:how does|怎么工作|怎么实现|怎么做|什么|理解|看看|看一下|了解|分析|explain|understand|架构|architecture|structure|overview|模块|概览|干什么|做什么|工作原理|逻辑|机制|流程|功能|结合度|效率|评估|调研|是什么|有什么|能用不|高效不|达标|起作用|科学|深入思考|源码|检查|审核|审查|验证|诊断)/i.test(msg),
+ callgraph: /(?:who calls|what calls|调用|call(?:graph|er|ee)|trace|链路|追踪|谁调|被谁调|调了谁|上下游|依赖关系|触发|路径|覆盖|介入)/i.test(msg),
+ search: /(?:where is|在哪|find|search|搜索|找|locate|哪里用|哪里定义|定义在|实现在|处理没|在源码|加不加)/i.test(msg),
+ };
}
-// Detect intent keywords (EN + ZH, derived from user's actual prompt history)
-const intentImpact = /(?:impact|影响|修改前|改之前|blast radius|before (?:edit|chang|modif)|risk|风险|改动范围|波及|问题在|bug|干扰|冲突|卡)/i.test(message);
-const intentModify = /(?:改(?!变)|修改|重构|\brefactor\b|\bchange\b|\brename\b|移动|\bmove\b|删(?!除文件)|\bremove\b|替换|\breplace\b|\bupdate\b|升级|\bmigrate\b|迁移|拆分|\bsplit\b|合并|\bmerge\b|提取|\bextract\b|改成|改为|换成|转为|异步|同步)/i.test(message);
-const intentUnderstand = /(?:how does|怎么工作|怎么实现|怎么做|什么|理解|看看|看一下|了解|分析|explain|understand|架构|architecture|structure|overview|模块|概览|干什么|做什么|工作原理|逻辑|机制|流程|功能|结合度|效率|评估|调研|是什么|有什么|能用不|高效不|达标|起作用|科学|深入思考|源码)/i.test(message);
-const intentCallgraph = /(?:who calls|what calls|调用|call(?:graph|er|ee)|trace|链路|追踪|谁调|被谁调|调了谁|上下游|依赖关系|触发|路径|覆盖|介入)/i.test(message);
-const intentSearch = /(?:where is|在哪|find|search|搜索|找|locate|哪里用|哪里定义|定义在|实现在|处理没|在源码|加不加)/i.test(message);
-
-// Need entities AND intent, or strong entity signal (qualified names like Foo::bar)
-const hasQualifiedSymbol = symbolCandidates.some(s => s.includes('::'));
-const hasIntent = intentImpact || intentModify || intentUnderstand || intentCallgraph || intentSearch;
-if (!hasIntent && !hasQualifiedSymbol && filePaths.length === 0) {
- process.exit(0);
+function determineQueryType(intents, symbols, filePaths, isCoolingDownFn) {
+ const hasStrict = symbols.symbols.length > 0 && !symbols.lowConfidence;
+ const hasQualified = symbols.symbols.some(s => s.includes('::'));
+ const hasAny = intents.impact || intents.modify || intents.implement || intents.understand || intents.callgraph || intents.search;
+
+ // Gate: need intent, qualified symbol, file path, or any symbol
+ if (!hasAny && !hasQualified && filePaths.length === 0 && symbols.symbols.length === 0) return null;
+
+ const cd = isCoolingDownFn || (() => false);
+
+ if ((intents.impact || intents.modify) && hasStrict && !cd('impact')) return { type: 'impact', symbol: symbols.symbols[0] };
+ if (intents.callgraph && hasStrict && !cd('callgraph')) return { type: 'callgraph', symbol: symbols.symbols[0] };
+ if (filePaths.length > 0 && !cd('overview')) return { type: 'overview', path: filePaths[0].replace(/\/[^/]+$/, '/') };
+ if ((intents.search || intents.implement || hasQualified) && symbols.symbols.length > 0 && !cd('search')) return { type: 'search', symbol: symbols.symbols[0] };
+ if ((intents.understand || !hasAny) && symbols.symbols.length > 0 && !cd('search')) return { type: 'search', symbol: symbols.symbols[0] };
+
+ return null;
}
-// --- Semantic output prefixes ---
-const PREFIXES = {
- impact: '[code-graph:impact] Blast radius — review before editing:',
- overview: '[code-graph:structure] Module structure:',
- callgraph: '[code-graph:callgraph] Call relationships:',
- search: '[code-graph:search] Relevant code:',
-};
+// --- Main execution (only when run directly) ---
+if (require.main === module) {
+ if (shouldSkip(message)) process.exit(0);
-// --- Run ONE targeted CLI query (per-type cooldown allows different types to fire) ---
-let queryType = null;
-let result = '';
-try {
- // Priority: impact/modify > callgraph > understand/overview > search
- // intentModify + symbol → inject impact so Claude knows blast radius before editing
- if ((intentImpact || intentModify) && symbolCandidates.length > 0 && !isCoolingDown('impact')) {
- queryType = 'impact';
- result = run('code-graph-mcp', ['impact', symbolCandidates[0]]);
- } else if (intentCallgraph && symbolCandidates.length > 0 && !isCoolingDown('callgraph')) {
- queryType = 'callgraph';
- result = run('code-graph-mcp', ['callgraph', symbolCandidates[0], '--depth', '2']);
- } else if (filePaths.length > 0 && (intentUnderstand || !hasIntent) && !isCoolingDown('overview')) {
- queryType = 'overview';
- const dir = filePaths[0].replace(/\/[^/]+$/, '/');
- result = run('code-graph-mcp', ['overview', dir]);
- } else if ((intentSearch || hasQualifiedSymbol) && symbolCandidates.length > 0 && !isCoolingDown('search')) {
- queryType = 'search';
- result = run('code-graph-mcp', ['search', symbolCandidates[0], '--limit', '8']);
- } else if (intentUnderstand && symbolCandidates.length > 0 && !isCoolingDown('search')) {
- queryType = 'search';
- result = run('code-graph-mcp', ['search', symbolCandidates[0], '--limit', '8']);
+ const filePaths = extractFilePaths(message);
+ const symbols = extractSymbols(message);
+ const intents = detectIntents(message);
+ const query = determineQueryType(intents, symbols, filePaths, isCoolingDown);
+
+ if (!query) process.exit(0);
+
+ const PREFIXES = {
+ impact: '[code-graph:impact] Blast radius — review before editing:',
+ overview: '[code-graph:structure] Module structure:',
+ callgraph: '[code-graph:callgraph] Call relationships:',
+ search: '[code-graph:search] Relevant code:',
+ };
+
+ try {
+ let result = '';
+ if (query.type === 'impact') result = run('code-graph-mcp', ['impact', query.symbol]);
+ else if (query.type === 'callgraph') result = run('code-graph-mcp', ['callgraph', query.symbol, '--depth', '2']);
+ else if (query.type === 'overview') result = run('code-graph-mcp', ['overview', query.path]);
+ else if (query.type === 'search') result = run('code-graph-mcp', ['search', query.symbol, '--limit', '8']);
+
+ if (result && result.trim()) {
+ markCooldown(query.type);
+ process.stdout.write(`${PREFIXES[query.type]}\n${result.trim()}\n`);
+ }
+ } catch {
+ process.exit(0);
}
-} catch {
- process.exit(0);
}
-if (result && result.trim() && queryType) {
- markCooldown(queryType);
- process.stdout.write(`${PREFIXES[queryType]}\n${result.trim()}\n`);
-}
+module.exports = { shouldSkip, extractFilePaths, extractSymbols, detectIntents, determineQueryType, STOP_WORDS, PLAIN_WORD_EXCLUDE };
// --- Helpers ---
diff --git a/claude-plugin/scripts/user-prompt-context.test.js b/claude-plugin/scripts/user-prompt-context.test.js
new file mode 100644
index 0000000..f96dd7f
--- /dev/null
+++ b/claude-plugin/scripts/user-prompt-context.test.js
@@ -0,0 +1,466 @@
+'use strict';
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const path = require('node:path');
+const fs = require('node:fs');
+
+const {
+ shouldSkip,
+ extractFilePaths,
+ extractSymbols,
+ detectIntents,
+ determineQueryType,
+} = require('./user-prompt-context');
+
+// ── shouldSkip ──────────────────────────────────────────
+
+test('shouldSkip: simple confirmations (EN)', () => {
+ for (const msg of ['yes', 'no', 'ok', 'done', 'y', 'n', 'commit', 'push', 'thanks']) {
+ assert.ok(shouldSkip(msg), `should skip "${msg}"`);
+ }
+});
+
+test('shouldSkip: simple confirmations (ZH)', () => {
+ for (const msg of ['继续', '确认', '好的', '好', '是的', '不', '可以', '行', '对', '提交', '推送', '没问题', '谢谢', '发布', '更新', '清理']) {
+ assert.ok(shouldSkip(msg), `should skip "${msg}"`);
+ }
+});
+
+test('shouldSkip: with trailing punctuation', () => {
+ assert.ok(shouldSkip('好的。'));
+ assert.ok(shouldSkip('ok!'));
+ assert.ok(shouldSkip('确认?'));
+});
+
+test('shouldSkip: action-only without code entities', () => {
+ assert.equal(shouldSkip('修复这些问题'), 'action-only');
+ assert.equal(shouldSkip('按优先级实施'), 'action-only');
+ assert.equal(shouldSkip('执行这个方案'), 'action-only');
+ assert.equal(shouldSkip('开始吧'), 'action-only');
+});
+
+test('shouldSkip: action with 3+ Latin chars passes through', () => {
+ assert.equal(shouldSkip('修复 parse_code 里的bug'), false);
+ assert.equal(shouldSkip('修复这段逻辑的bug'), false); // "bug" = 3 chars
+ assert.equal(shouldSkip('修复 API 的问题'), false); // "API" = 3 chars
+});
+
+test('shouldSkip: NOT skip legitimate code tasks', () => {
+ assert.equal(shouldSkip('帮我写一个工具函数'), false);
+ assert.equal(shouldSkip('帮我优化一下这个查询'), false);
+ assert.equal(shouldSkip('优化 parse_code 的性能'), false);
+ assert.equal(shouldSkip('看看 src/mcp/ 模块的代码结构'), false);
+ assert.equal(shouldSkip('重构一下这个模块'), false);
+});
+
+test('shouldSkip: messages below length threshold exit early in main', () => {
+ // The 8-char minimum is checked in the main block, not in shouldSkip
+ // shouldSkip itself doesn't enforce length
+ assert.equal(shouldSkip('短消息很短'), false); // passes shouldSkip but would exit in main
+});
+
+// ── extractFilePaths ────────────────────────────────────
+
+test('extractFilePaths: extracts src/ paths', () => {
+ assert.deepEqual(extractFilePaths('看看 src/mcp/server.rs'), ['src/mcp/server.rs']);
+ assert.deepEqual(extractFilePaths('修改 src/parser/relations.rs 和 src/storage/db.rs'), ['src/parser/relations.rs', 'src/storage/db.rs']);
+});
+
+test('extractFilePaths: extracts lib/test/pkg paths', () => {
+ assert.deepEqual(extractFilePaths('check lib/utils/helpers.js'), ['lib/utils/helpers.js']);
+ assert.deepEqual(extractFilePaths('test/integration.rs is failing'), ['test/integration.rs']);
+});
+
+test('extractFilePaths: limits to 2 paths', () => {
+ const result = extractFilePaths('src/a.rs src/b.rs src/c.rs');
+ assert.equal(result.length, 2);
+});
+
+test('extractFilePaths: no match for non-code paths', () => {
+ assert.deepEqual(extractFilePaths('这个函数有问题'), []);
+ assert.deepEqual(extractFilePaths('update the readme'), []);
+});
+
+// ── extractSymbols ──────────────────────────────────────
+
+test('extractSymbols: snake_case', () => {
+ const r = extractSymbols('修改 parse_code 函数');
+ assert.deepEqual(r.symbols, ['parse_code']);
+ assert.equal(r.lowConfidence, false);
+});
+
+test('extractSymbols: camelCase', () => {
+ const r = extractSymbols('fix the handleMessage function');
+ assert.ok(r.symbols.includes('handleMessage'));
+ assert.equal(r.lowConfidence, false);
+});
+
+test('extractSymbols: PascalCase compound', () => {
+ const r = extractSymbols('implement McpServer class');
+ assert.ok(r.symbols.includes('McpServer'));
+});
+
+test('extractSymbols: qualified names (Foo::bar)', () => {
+ const r = extractSymbols('check Foo::bar::baz');
+ assert.ok(r.symbols.some(s => s.includes('::')));
+});
+
+test('extractSymbols: backtick-quoted fallback', () => {
+ const r = extractSymbols('修改 `parse` 函数');
+ assert.ok(r.symbols.includes('parse'));
+});
+
+test('extractSymbols: backtick with longer name', () => {
+ const r = extractSymbols('看看 `fts5_search` 怎么实现的');
+ assert.ok(r.symbols.includes('fts5_search'));
+});
+
+test('extractSymbols: plain word fallback (low confidence)', () => {
+ const r = extractSymbols('write tests for the embedding module');
+ assert.ok(r.symbols.includes('embedding'));
+ assert.equal(r.lowConfidence, true);
+});
+
+test('extractSymbols: plain words excluded (common English verbs)', () => {
+ const r = extractSymbols('help me understand the refactor approach');
+ // "understand" and "refactor" are excluded, "approach" is excluded
+ assert.equal(r.symbols.length, 0);
+});
+
+test('extractSymbols: stop words filtered', () => {
+ const r = extractSymbols('fix the default function');
+ // "default" and "function" are stop words
+ assert.equal(r.symbols.length, 0);
+});
+
+test('extractSymbols: limits to 3 symbols', () => {
+ const r = extractSymbols('modify parse_code and run_full_index and extract_relations and hash_file');
+ assert.ok(r.symbols.length <= 3);
+});
+
+// ── detectIntents ───────────────────────────────────────
+
+// --- Impact intent ---
+test('detectIntents: impact (EN)', () => {
+ assert.ok(detectIntents('what is the impact of this change').impact);
+ assert.ok(detectIntents('check the risk of modifying this').impact);
+ assert.ok(detectIntents('this bug is critical').impact);
+});
+
+test('detectIntents: impact (ZH)', () => {
+ assert.ok(detectIntents('这个改动有什么影响').impact);
+ assert.ok(detectIntents('改动范围有多大').impact);
+ assert.ok(detectIntents('会不会跟其他模块冲突').impact);
+ assert.ok(detectIntents('修改前先看看').impact);
+ assert.ok(detectIntents('有什么风险').impact);
+ assert.ok(detectIntents('这个bug怎么回事').impact);
+});
+
+// --- Modify intent ---
+test('detectIntents: modify (EN)', () => {
+ assert.ok(detectIntents('refactor this module').modify);
+ assert.ok(detectIntents('rename the function').modify);
+ assert.ok(detectIntents('fix the broken test').modify);
+ assert.ok(detectIntents('update the config').modify);
+ assert.ok(detectIntents('remove deprecated code').modify);
+ assert.ok(detectIntents('replace with new impl').modify);
+});
+
+test('detectIntents: modify (ZH)', () => {
+ const words = ['修改', '修复', '重构', '优化', '简化', '精简', '适配', '统一', '修正', '调整', '去掉', '整理', '清理', '解耦', '更新', '升级', '迁移', '拆分', '合并', '提取'];
+ for (const w of words) {
+ assert.ok(detectIntents(`${w}这个模块`).modify, `"${w}" should trigger modify`);
+ }
+});
+
+test('detectIntents: modify (ZH compound)', () => {
+ assert.ok(detectIntents('把这个函数改成异步的').modify);
+ assert.ok(detectIntents('把返回值类型换成 Result').modify);
+ assert.ok(detectIntents('把同步改成异步').modify);
+});
+
+// --- Implement intent ---
+test('detectIntents: implement (EN)', () => {
+ assert.ok(detectIntents('add a new tool').implement);
+ assert.ok(detectIntents('implement error handling').implement);
+ assert.ok(detectIntents('create a helper function').implement);
+ assert.ok(detectIntents('build the CI pipeline').implement);
+ assert.ok(detectIntents('write unit tests').implement);
+});
+
+test('detectIntents: implement (ZH)', () => {
+ const words = ['新增', '添加', '实现', '创建', '编写', '开发', '增加', '加上', '加个', '搭建', '补充', '引入', '支持', '封装', '接入', '对接', '配置'];
+ for (const w of words) {
+ assert.ok(detectIntents(`${w}一个功能`).implement, `"${w}" should trigger implement`);
+ }
+});
+
+test('detectIntents: implement - "写" variants', () => {
+ assert.ok(detectIntents('写个测试').implement);
+ assert.ok(detectIntents('写一个工具函数').implement);
+ assert.ok(detectIntents('帮我写一个函数').implement);
+});
+
+// --- Understand intent ---
+test('detectIntents: understand (EN)', () => {
+ assert.ok(detectIntents('how does this module work').understand);
+ assert.ok(detectIntents('explain the architecture').understand);
+});
+
+test('detectIntents: understand (ZH)', () => {
+ const words = ['看看', '看一下', '理解', '了解', '分析', '评估', '检查', '审核', '审查', '验证', '诊断', '深入思考'];
+ for (const w of words) {
+ assert.ok(detectIntents(`${w}这段代码`).understand, `"${w}" should trigger understand`);
+ }
+});
+
+test('detectIntents: understand (ZH question patterns)', () => {
+ assert.ok(detectIntents('这个模块是干什么的').understand);
+ assert.ok(detectIntents('工作原理是什么').understand);
+ assert.ok(detectIntents('整个流程是怎么走的').understand);
+ assert.ok(detectIntents('这个功能怎么实现的').understand);
+});
+
+// --- Callgraph intent ---
+test('detectIntents: callgraph (EN)', () => {
+ assert.ok(detectIntents('who calls this function').callgraph);
+ assert.ok(detectIntents('what calls parse_code').callgraph);
+ assert.ok(detectIntents('trace the request flow').callgraph);
+});
+
+test('detectIntents: callgraph (ZH)', () => {
+ assert.ok(detectIntents('这个函数被谁调了').callgraph);
+ assert.ok(detectIntents('看看调用链路').callgraph);
+ assert.ok(detectIntents('追踪一下请求路径').callgraph);
+ assert.ok(detectIntents('上下游依赖关系是什么').callgraph);
+ assert.ok(detectIntents('这个事件怎么触发的').callgraph);
+});
+
+// --- Search intent ---
+test('detectIntents: search (EN)', () => {
+ assert.ok(detectIntents('where is the config defined').search);
+ assert.ok(detectIntents('find the error handling code').search);
+ assert.ok(detectIntents('search for all usages').search);
+});
+
+test('detectIntents: search (ZH)', () => {
+ assert.ok(detectIntents('这个函数定义在哪').search);
+ assert.ok(detectIntents('找一下处理错误的代码').search);
+ assert.ok(detectIntents('搜索所有用到这个类型的地方').search);
+ assert.ok(detectIntents('在哪里用了这个常量').search);
+});
+
+// --- No false positives ---
+test('detectIntents: simple confirmations have no code intent', () => {
+ const r = detectIntents('好的');
+ // "什么" would match in some words, but "好的" shouldn't trigger understand
+ assert.equal(r.modify, false);
+ assert.equal(r.implement, false);
+ assert.equal(r.callgraph, false);
+ assert.equal(r.search, false);
+});
+
+// ── determineQueryType (priority logic) ─────────────────
+
+test('priority: impact/modify + strict symbol → impact', () => {
+ const intents = { impact: true, modify: false, implement: false, understand: false, callgraph: false, search: false };
+ const symbols = { symbols: ['parse_code'], lowConfidence: false };
+ const result = determineQueryType(intents, symbols, []);
+ assert.equal(result.type, 'impact');
+ assert.equal(result.symbol, 'parse_code');
+});
+
+test('priority: modify + strict symbol → impact', () => {
+ const intents = { impact: false, modify: true, implement: false, understand: false, callgraph: false, search: false };
+ const symbols = { symbols: ['handleMessage'], lowConfidence: false };
+ const result = determineQueryType(intents, symbols, []);
+ assert.equal(result.type, 'impact');
+});
+
+test('priority: modify + low-confidence symbol → NOT impact (falls to overview/search)', () => {
+ const intents = { impact: false, modify: true, implement: false, understand: false, callgraph: false, search: false };
+ const symbols = { symbols: ['embedding'], lowConfidence: true };
+ const result = determineQueryType(intents, symbols, ['src/embed/']);
+ // Should fall through to overview (file paths exist)
+ assert.equal(result.type, 'overview');
+});
+
+test('priority: callgraph + strict symbol → callgraph', () => {
+ const intents = { impact: false, modify: false, implement: false, understand: false, callgraph: true, search: false };
+ const symbols = { symbols: ['parse_code'], lowConfidence: false };
+ const result = determineQueryType(intents, symbols, []);
+ assert.equal(result.type, 'callgraph');
+});
+
+test('priority: file paths → overview (regardless of intent)', () => {
+ const intents = { impact: false, modify: true, implement: false, understand: false, callgraph: false, search: false };
+ const symbols = { symbols: [], lowConfidence: false };
+ const result = determineQueryType(intents, symbols, ['src/storage/queries.rs']);
+ assert.equal(result.type, 'overview');
+ assert.equal(result.path, 'src/storage/');
+});
+
+test('priority: search intent + symbol → search', () => {
+ const intents = { impact: false, modify: false, implement: false, understand: false, callgraph: false, search: true };
+ const symbols = { symbols: ['parse_code'], lowConfidence: false };
+ const result = determineQueryType(intents, symbols, []);
+ assert.equal(result.type, 'search');
+});
+
+test('priority: implement intent + symbol → search', () => {
+ const intents = { impact: false, modify: false, implement: true, understand: false, callgraph: false, search: false };
+ const symbols = { symbols: ['embedding'], lowConfidence: true };
+ const result = determineQueryType(intents, symbols, []);
+ assert.equal(result.type, 'search');
+});
+
+test('priority: understand + symbol → search', () => {
+ const intents = { impact: false, modify: false, implement: false, understand: true, callgraph: false, search: false };
+ const symbols = { symbols: ['pipeline'], lowConfidence: true };
+ const result = determineQueryType(intents, symbols, []);
+ assert.equal(result.type, 'search');
+});
+
+test('priority: no intent, no symbol, no path → null', () => {
+ const intents = { impact: false, modify: false, implement: false, understand: false, callgraph: false, search: false };
+ const symbols = { symbols: [], lowConfidence: false };
+ const result = determineQueryType(intents, symbols, []);
+ assert.equal(result, null);
+});
+
+test('priority: cooldown blocks query', () => {
+ const intents = { impact: true, modify: false, implement: false, understand: false, callgraph: false, search: false };
+ const symbols = { symbols: ['parse_code'], lowConfidence: false };
+ const result = determineQueryType(intents, symbols, [], (type) => type === 'impact');
+ // Impact blocked by cooldown, falls through; no file path, no search intent → try search via understand fallback
+ // Actually: no understand intent and hasAny=true, so the last condition (!hasAny) is false → null
+ // But symbol exists and we have filePaths=[] → falls to search via implement/qualified check → no
+ // Actually it should return null since all fallbacks require conditions not met
+ assert.equal(result, null);
+});
+
+test('priority: cooldown on impact → falls to overview when file paths exist', () => {
+ const intents = { impact: true, modify: false, implement: false, understand: false, callgraph: false, search: false };
+ const symbols = { symbols: ['parse_code'], lowConfidence: false };
+ const result = determineQueryType(intents, symbols, ['src/parser/mod.rs'], (type) => type === 'impact');
+ assert.equal(result.type, 'overview');
+});
+
+// ── Full integration: message → query type ──────────────
+
+function analyze(msg) {
+ if (shouldSkip(msg)) return { skipped: true };
+ const fp = extractFilePaths(msg);
+ const sym = extractSymbols(msg);
+ const intents = detectIntents(msg);
+ const query = determineQueryType(intents, sym, fp);
+ return { query, intents, symbols: sym, filePaths: fp };
+}
+
+test('integration: 修改 parse_code 函数增加错误处理 → impact', () => {
+ const r = analyze('修改 parse_code 函数增加错误处理');
+ assert.equal(r.query.type, 'impact');
+ assert.equal(r.query.symbol, 'parse_code');
+});
+
+test('integration: 看看 src/mcp/ 模块的代码结构 → overview', () => {
+ const r = analyze('看看 src/mcp/ 模块的代码结构');
+ assert.equal(r.query.type, 'overview');
+});
+
+test('integration: refactor src/storage/queries.rs → overview (not impact on "refactor")', () => {
+ const r = analyze('refactor src/storage/queries.rs to use parameterized queries');
+ assert.equal(r.query.type, 'overview');
+ assert.ok(r.query.path.includes('src/storage/'));
+});
+
+test('integration: help me understand the indexer pipeline → search', () => {
+ const r = analyze('help me understand the indexer pipeline');
+ assert.equal(r.query.type, 'search');
+ assert.equal(r.query.symbol, 'pipeline');
+});
+
+test('integration: write tests for the embedding module → search', () => {
+ const r = analyze('write tests for the embedding module');
+ assert.equal(r.query.type, 'search');
+ assert.equal(r.query.symbol, 'embedding');
+});
+
+test('integration: 修复这段逻辑的bug → not skipped (bug=3 chars)', () => {
+ const r = analyze('修复这段逻辑的bug');
+ assert.ok(!r.skipped);
+ assert.ok(r.intents.impact); // "bug"
+ assert.ok(r.intents.modify); // "修复"
+});
+
+test('integration: 按优先级修复这些问题 → skipped (no code entity)', () => {
+ const r = analyze('按优先级修复这些问题');
+ assert.ok(r.skipped);
+});
+
+test('integration: 帮我写一个工具函数 → implement intent', () => {
+ const r = analyze('帮我写一个工具函数');
+ assert.ok(!r.skipped);
+ assert.ok(r.intents.implement);
+});
+
+test('integration: 对整个项目进行一次完整的代码审核 → understand', () => {
+ const r = analyze('对整个项目进行一次完整的代码审核');
+ assert.ok(r.intents.understand);
+});
+
+test('integration: 更新一下readme.md → modify intent', () => {
+ const r = analyze('更新一下readme.md这个文件');
+ assert.ok(r.intents.modify);
+});
+
+test('integration: 配置 pre-commit hook → implement intent', () => {
+ const r = analyze('配置提交代码时的git pre-commit hook检查');
+ assert.ok(r.intents.implement);
+});
+
+test('integration: 检查下我们插件上下文token占用情况 → understand', () => {
+ const r = analyze('检查下我们插件上下文token占用情况');
+ assert.ok(r.intents.understand);
+});
+
+test('integration: 诊断一下性能问题 → understand', () => {
+ const r = analyze('诊断一下性能问题');
+ assert.ok(r.intents.understand);
+});
+
+test('integration: simple confirmation → skipped', () => {
+ assert.ok(analyze('好的').skipped);
+ assert.ok(analyze('继续').skipped);
+ assert.ok(analyze('ok').skipped);
+});
+
+// ── Skill files validation ──────────────────────────────
+
+test('skills: explore.md has correct frontmatter', () => {
+ const content = fs.readFileSync(path.join(__dirname, '../skills/explore.md'), 'utf8');
+ assert.match(content, /^---\nname: explore/);
+ assert.match(content, /description:/);
+});
+
+test('skills: index.md has correct frontmatter', () => {
+ const content = fs.readFileSync(path.join(__dirname, '../skills/index.md'), 'utf8');
+ assert.match(content, /^---\nname: index/);
+ assert.match(content, /description:/);
+});
+
+test('skills: commands directory is empty (all converted to skills)', () => {
+ const commandsDir = path.join(__dirname, '../commands');
+ const exists = fs.existsSync(commandsDir);
+ if (exists) {
+ const files = fs.readdirSync(commandsDir).filter(f => f.endsWith('.md'));
+ assert.equal(files.length, 0, 'commands/ should have no .md files');
+ }
+ // Directory not existing is also valid
+});
+
+test('skills: only expected skills exist', () => {
+ const skillsDir = path.join(__dirname, '../skills');
+ const files = fs.readdirSync(skillsDir).filter(f => f.endsWith('.md')).sort();
+ assert.deepEqual(files, ['explore.md', 'index.md']);
+});
diff --git a/claude-plugin/skills/code-navigation.md b/claude-plugin/skills/code-navigation.md
deleted file mode 100644
index 137c6ff..0000000
--- a/claude-plugin/skills/code-navigation.md
+++ /dev/null
@@ -1,20 +0,0 @@
----
-name: code-navigation
-description: Code search and understanding via CLI. Use when exploring code structure, searching by concept, or checking impact before edits.
----
-
-# Code Graph CLI
-
-Indexed project. Use Bash — one command replaces multi-file Grep/Read:
-
-| Task | Command | Replaces |
-|------|---------|----------|
-| grep + AST context | `code-graph-mcp grep "pattern" [path]` | Grep |
-| search by concept | `code-graph-mcp search "query"` | Grep (no exact name needed) |
-| structural search | `code-graph-mcp ast-search "q" --type fn --returns Result` | — |
-| project map | `code-graph-mcp map` | Read multiple files |
-| module overview | `code-graph-mcp overview src/path/` | Read directory files |
-| call graph | `code-graph-mcp callgraph symbol` | Grep + Read tracing |
-| impact analysis | `code-graph-mcp impact symbol` | — |
-
-Still use Grep for exact strings/constants/regex. Still use Read for files you'll edit.
diff --git a/claude-plugin/skills/explore.md b/claude-plugin/skills/explore.md
new file mode 100644
index 0000000..83891b5
--- /dev/null
+++ b/claude-plugin/skills/explore.md
@@ -0,0 +1,22 @@
+---
+name: explore
+description: |
+ Understand code structure efficiently using the AST index. Use BEFORE reading
+ files one by one — when starting work in unfamiliar code, exploring a module
+ before changes, or finding the right file to edit. One overview call replaces
+ 5+ Read calls and saves significant context.
+---
+
+# Explore Code (indexed project)
+
+Use these BEFORE reading individual files:
+
+| Need | Command | Replaces |
+|------|---------|----------|
+| Module structure | `code-graph-mcp overview ` | 5+ Read calls |
+| Project architecture | `code-graph-mcp map --compact` | ls + README |
+| Who calls / what calls | `code-graph-mcp callgraph ` | Grep + manual trace |
+| Find by concept | `code-graph-mcp search "concept"` | 3+ Grep attempts |
+| Impact before edit | `code-graph-mcp impact ` | Grep for callers |
+
+**Workflow**: overview first → Read only the file you will edit.
diff --git a/claude-plugin/skills/index.md b/claude-plugin/skills/index.md
new file mode 100644
index 0000000..cb31a8b
--- /dev/null
+++ b/claude-plugin/skills/index.md
@@ -0,0 +1,24 @@
+---
+name: index
+description: |
+ Diagnose and fix code-graph index issues. Use when: search returns unexpected/empty
+ results, or after major codebase restructuring. These management commands are NOT
+ exposed via MCP tools — this skill is the only way to access them.
+---
+
+# Index Maintenance
+
+## Check health
+```bash
+code-graph-mcp health-check
+```
+
+## Rebuild (incremental — only changed files)
+```bash
+code-graph-mcp incremental-index
+```
+
+## Full rebuild (when incremental isn't enough)
+```bash
+rm -rf .code-graph/ && code-graph-mcp incremental-index
+```
diff --git a/src/cli.rs b/src/cli.rs
index b0b6731..0ad2e2a 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -61,6 +61,11 @@ fn get_positional(args: &[String], index: usize) -> Option<&str> {
}
continue;
}
+ // Skip single-dash flags (e.g., -h, -V)
+ if args[i].starts_with('-') && args[i].len() > 1 {
+ i += 1;
+ continue;
+ }
if pos == index {
return Some(&args[i]);
}
@@ -465,8 +470,10 @@ fn parse_rg_json(stdout: &[u8], project_root: &Path) -> Vec {
let text = data["lines"]["text"].as_str().unwrap_or("").to_string();
// Make path relative to project root
+ let root_prefix = root_str.trim_end_matches('/');
let relative_path = path_str
- .strip_prefix(root_str.as_str())
+ .strip_prefix(root_prefix)
+ .or_else(|| path_str.strip_prefix(&root_str))
.unwrap_or(path_str)
.trim_start_matches('/');
@@ -2006,7 +2013,7 @@ pub fn cmd_dead_code(project_root: &Path, args: &[String]) -> Result<()> {
/// Run benchmark: full index, incremental index, query latency, DB size, token savings.
pub fn cmd_benchmark(project_root: &Path, args: &[String]) -> Result<()> {
use crate::domain::CODE_GRAPH_DIR;
- use crate::indexer::pipeline::run_full_index;
+ use crate::indexer::pipeline::{run_full_index, run_incremental_index};
use std::time::Instant;
let json_mode = has_flag(args, "--json");
@@ -2036,7 +2043,7 @@ pub fn cmd_benchmark(project_root: &Path, args: &[String]) -> Result<()> {
// 2. Incremental index (no-change detection — should be fast)
let t_incr = Instant::now();
- let _ = run_full_index(&bench_db, project_root, None, None)?;
+ let _ = run_incremental_index(&bench_db, project_root, None, None)?;
let incr_index_ms = t_incr.elapsed().as_millis() as u64;
eprintln!("[benchmark] Incremental (no-change): {}ms", incr_index_ms);
diff --git a/src/domain.rs b/src/domain.rs
index 3dbd13e..9d56f1e 100644
--- a/src/domain.rs
+++ b/src/domain.rs
@@ -98,6 +98,11 @@ pub fn is_test_symbol(name: &str, file_path: &str) -> bool {
|| file_path.ends_with(".spec.ts") || file_path.ends_with(".spec.js")
}
+/// Enhanced test detection: combines naming heuristic with AST-level is_test flag.
+pub fn is_test_symbol_or_annotated(name: &str, file_path: &str, is_test_from_ast: bool) -> bool {
+ is_test_from_ast || is_test_symbol(name, file_path)
+}
+
// -- Node type normalization --
/// Normalize shorthand type filter into canonical AST node types.
/// Shared by CLI and MCP tool implementations.
diff --git a/src/embedding/model.rs b/src/embedding/model.rs
index 3ab50f6..8009ed5 100644
--- a/src/embedding/model.rs
+++ b/src/embedding/model.rs
@@ -248,6 +248,10 @@ mod inner {
fn embed_batch_chunk_pre_tokenized(&self, encodings: &[&tokenizers::Encoding]) -> Result>> {
let max_len = encodings.iter().map(|e| e.get_ids().len()).max().unwrap_or(0);
let batch_size = encodings.len();
+ if max_len == 0 {
+ // All encodings are empty — return zero vectors
+ return Ok(vec![vec![0f32; super::EMBEDDING_DIM]; batch_size]);
+ }
// Build padded tensors
let mut all_ids = vec![0u32; batch_size * max_len];
diff --git a/src/graph/query.rs b/src/graph/query.rs
index 0764656..5902b6a 100644
--- a/src/graph/query.rs
+++ b/src/graph/query.rs
@@ -102,7 +102,8 @@ fn query_direction(
JOIN nodes n ON n.id = cg.node_id
JOIN files f ON f.id = n.file_id
GROUP BY cg.node_id
- ORDER BY depth"
+ ORDER BY depth
+ LIMIT 200"
);
let mut stmt = conn.prepare(&sql)?;
diff --git a/src/indexer/pipeline.rs b/src/indexer/pipeline.rs
index e47a7bb..a879779 100644
--- a/src/indexer/pipeline.rs
+++ b/src/indexer/pipeline.rs
@@ -14,6 +14,7 @@ use crate::storage::db::Database;
use crate::storage::queries::{
delete_files_by_paths, delete_nodes_by_file,
get_all_file_hashes, get_all_node_names_with_ids, get_dirty_node_ids, get_edges_batch,
+ get_inbound_cross_file_edges,
get_nodes_by_file_path,
get_nodes_missing_context, get_nodes_with_files_by_ids,
insert_edge_cached, insert_node_cached,
@@ -622,6 +623,8 @@ fn index_files(
.collect();
let mut batch_parsed: Vec = Vec::new();
+ // Saved inbound edges from other files → batch files (to restore after cascade delete)
+ let mut saved_inbound_edges: Vec<(i64, String, String, Option)> = Vec::new();
// --- Phase 1b: Sequential DB inserts ---
for pp in pre_parsed {
@@ -632,6 +635,9 @@ fn index_files(
language: Some(pp.language.clone()),
})?;
+ // Save cross-file inbound edges before cascade delete destroys them
+ saved_inbound_edges.extend(get_inbound_cross_file_edges(db.conn(), file_id)?);
+
delete_nodes_by_file(db.conn(), file_id)?;
let mut node_ids = Vec::new();
@@ -861,6 +867,35 @@ fn index_files(
}
}
+ // Phase 2c: Restore cross-file inbound edges lost to cascade delete.
+ // When a file is re-indexed, its old nodes are deleted (cascade-deleting edges).
+ // Edges from OTHER files into the re-indexed file must be rebuilt using new node IDs.
+ if !saved_inbound_edges.is_empty() {
+ // Build name → new_node_id map for batch files only
+ let mut batch_name_to_ids: HashMap<&str, Vec> = HashMap::new();
+ for pf in &batch_parsed {
+ for (id, name) in pf.node_ids.iter().zip(pf.node_names.iter()) {
+ batch_name_to_ids.entry(name.as_str()).or_default().push(*id);
+ }
+ }
+
+ let mut restored = 0usize;
+ for (source_id, target_name, relation, metadata) in &saved_inbound_edges {
+ if let Some(new_target_ids) = batch_name_to_ids.get(target_name.as_str()) {
+ for &new_tgt_id in new_target_ids {
+ if *source_id != new_tgt_id
+ && insert_edge_cached(db.conn(), *source_id, new_tgt_id, relation, metadata.as_deref())? {
+ total_edges_created += 1;
+ restored += 1;
+ }
+ }
+ }
+ }
+ if restored > 0 {
+ tracing::debug!("[index] Restored {} cross-file inbound edges", restored);
+ }
+ }
+
tx.commit()?;
let batch_file_count = batch_parsed.len();
diff --git a/src/indexer/watcher.rs b/src/indexer/watcher.rs
index 127bc4b..829804f 100644
--- a/src/indexer/watcher.rs
+++ b/src/indexer/watcher.rs
@@ -68,7 +68,7 @@ impl FileWatcher {
mod tests {
use super::*;
use tempfile::TempDir;
- use std::{fs, time::Duration, thread};
+ use std::{fs, time::Duration};
#[test]
fn test_watcher_detects_file_changes() {
diff --git a/src/main.rs b/src/main.rs
index e821693..aa6bdc7 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,5 +1,19 @@
use anyhow::Result;
use std::io::{self, BufRead, Read, Write};
+use std::sync::{Arc, Mutex};
+
+/// Newtype wrapper around `Arc>` so both the main loop
+/// and `McpServer::send_notification` share a single, mutex-protected handle.
+struct SharedStdout(Arc>);
+
+impl Write for SharedStdout {
+ fn write(&mut self, buf: &[u8]) -> io::Result {
+ self.0.lock().unwrap().write(buf)
+ }
+ fn flush(&mut self) -> io::Result<()> {
+ self.0.lock().unwrap().flush()
+ }
+}
fn main() -> Result<()> {
let args: Vec = std::env::args().collect();
@@ -168,25 +182,28 @@ fn run_serve() -> Result<()> {
tracing::info!("[session] Started v{}, project: {}", env!("CARGO_PKG_VERSION"), project_root.display());
- // Enable MCP progress/log notifications via stdout
- server.set_notify_writer(Box::new(io::stdout()));
+ // Shared stdout handle: prevents interleaved JSON when background threads
+ // send notifications concurrently with the main loop writing responses.
+ let stdout_shared = Arc::new(Mutex::new(io::stdout()));
+
+ // Enable MCP progress/log notifications via the same shared handle
+ server.set_notify_writer(Box::new(SharedStdout(Arc::clone(&stdout_shared))));
let stdin = io::stdin();
- let mut stdout = io::stdout();
let mut reader = stdin.lock();
let mut buf = String::new();
const MAX_MESSAGE_SIZE: usize = 10 * 1024 * 1024; // 10MB
loop {
buf.clear();
- let n = reader.by_ref().take((MAX_MESSAGE_SIZE + 1) as u64).read_line(&mut buf)?;
+ let n = reader.by_ref().take(MAX_MESSAGE_SIZE as u64).read_line(&mut buf)?;
if n == 0 {
break; // EOF
}
if buf.trim().is_empty() {
continue;
}
- if buf.len() > MAX_MESSAGE_SIZE {
+ if buf.len() >= MAX_MESSAGE_SIZE && !buf.ends_with('\n') {
let oversized_len = buf.len();
let needs_drain = !buf.ends_with('\n');
// Free the oversized buffer before draining to avoid 2x peak allocation
@@ -205,15 +222,19 @@ fn run_serve() -> Result<()> {
"message": format!("Message too large: {} bytes (max {})", oversized_len, MAX_MESSAGE_SIZE)
}
});
- writeln!(stdout, "{}", err_resp)?;
- stdout.flush()?;
+ {
+ let mut out = stdout_shared.lock().unwrap();
+ writeln!(out, "{}", err_resp)?;
+ out.flush()?;
+ }
continue;
}
match server.handle_message(&buf) {
Ok(Some(response)) => {
- writeln!(stdout, "{}", response)?;
- stdout.flush()?;
+ let mut out = stdout_shared.lock().unwrap();
+ writeln!(out, "{}", response)?;
+ out.flush()?;
}
Ok(None) => {}
Err(e) => {
@@ -226,8 +247,9 @@ fn run_serve() -> Result<()> {
"message": format!("Internal error: {}", e)
}
});
- writeln!(stdout, "{}", err_resp)?;
- stdout.flush()?;
+ let mut out = stdout_shared.lock().unwrap();
+ writeln!(out, "{}", err_resp)?;
+ out.flush()?;
}
}
diff --git a/src/mcp/protocol.rs b/src/mcp/protocol.rs
index e69a61f..cb773a5 100644
--- a/src/mcp/protocol.rs
+++ b/src/mcp/protocol.rs
@@ -28,7 +28,6 @@ impl JsonRpcRequest {
#[derive(Debug, Serialize)]
pub struct JsonRpcResponse {
pub jsonrpc: String,
- #[serde(skip_serializing_if = "Option::is_none")]
pub id: Option,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option,
diff --git a/src/mcp/server/helpers.rs b/src/mcp/server/helpers.rs
index 2828df7..5d298d0 100644
--- a/src/mcp/server/helpers.rs
+++ b/src/mcp/server/helpers.rs
@@ -22,7 +22,7 @@ pub(super) fn parse_route_input(input: &str) -> (Option, &str) {
let trimmed = input.trim();
if let Some(space_idx) = trimmed.find(' ') {
let prefix = &trimmed[..space_idx];
- let methods = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS", "USE"];
+ let methods = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"];
if methods.contains(&prefix.to_uppercase().as_str()) {
return (Some(prefix.to_uppercase()), trimmed[space_idx..].trim());
}
diff --git a/src/mcp/server/mod.rs b/src/mcp/server/mod.rs
index 4ca1d1e..9076d1b 100644
--- a/src/mcp/server/mod.rs
+++ b/src/mcp/server/mod.rs
@@ -225,6 +225,8 @@ pub struct McpServer {
pub(super) db: Database,
pub(super) embedding_model: Mutex