Skip to content

Commit 4ddc27d

Browse files
sdsrssclaude
andcommitted
feat(parser): add is_test detection and relevance quality decay
- Parser: detect #[cfg(test)], #[test], and mod tests blocks via prev_sibling() attribute walking; propagate in_test_context through AST recursion. ParsedNode gains is_test: bool field. - Schema: v4→v5 migration adds is_test column; INDEX_VERSION bumped to trigger full rebuild for populating is_test values. - Queries: hot_functions and key_symbols filter AND n.is_test = 0, removing test helpers (new_test, tool_call_json etc.) from results. - Search: apply query quality factor to relevance scores so short/vague queries (e.g. "x") produce lower scores (0.83→0.25) while good multi-word queries are unaffected. - UX: fix dependency_graph grammar ("1 files" → "1 file depends"). - Tests: add 3 integration tests for project_map (entry points, hot_functions filtering, module dependencies). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent adae9c4 commit 4ddc27d

12 files changed

Lines changed: 129 additions & 34 deletions

File tree

.claude-plugin/marketplace.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@
55
},
66
"metadata": {
77
"description": "AST knowledge graph plugin for Claude Code — semantic search, call graph, HTTP tracing, impact analysis",
8-
"version": "0.5.25"
8+
"version": "0.5.26"
99
},
1010
"plugins": [
1111
{
1212
"name": "code-graph-mcp",
1313
"source": "./claude-plugin",
1414
"description": "AST knowledge graph for intelligent code navigation — auto-indexes your codebase and provides semantic search, call graph traversal, HTTP route tracing, and impact analysis via MCP tools",
15-
"version": "0.5.25",
15+
"version": "0.5.26",
1616
"author": {
1717
"name": "sdsrs"
1818
},

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "code-graph-mcp"
3-
version = "0.5.25"
3+
version = "0.5.26"
44
edition = "2021"
55

66
[features]

claude-plugin/.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,6 @@
44
"author": {
55
"name": "sdsrs"
66
},
7-
"version": "0.5.25",
7+
"version": "0.5.26",
88
"keywords": ["code-graph", "ast", "navigation", "mcp", "knowledge-graph"]
99
}

src/domain.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ pub const REL_EXPORTS: &str = "exports";
1515
// nodes or edges for the same source files. The server will detect a mismatch
1616
// and automatically clear + rebuild the index.
1717
// This is separate from SCHEMA_VERSION (which tracks table structure changes).
18-
pub const INDEX_VERSION: i32 = 1;
18+
pub const INDEX_VERSION: i32 = 2;
1919

2020
// -- Embedding --
2121
pub const EMBEDDING_DIM: usize = 384;

src/indexer/pipeline.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -530,6 +530,7 @@ fn index_files(
530530
name_tokens: None,
531531
return_type: None,
532532
param_types: None,
533+
is_test: false,
533534
})?;
534535
node_ids.push(module_node_id);
535536
node_names.push("<module>".into());
@@ -551,6 +552,7 @@ fn index_files(
551552
name_tokens: Some(name_tokens),
552553
return_type: pn.return_type.clone(),
553554
param_types: pn.param_types.clone(),
555+
is_test: pn.is_test,
554556
})?;
555557
node_ids.push(node_id);
556558
node_names.push(pn.name.clone());
@@ -716,6 +718,7 @@ fn index_files(
716718
name_tokens: None,
717719
return_type: None,
718720
param_types: None,
721+
is_test: false,
719722
})?;
720723
ext_node_ids.insert(module_name.clone(), node_id);
721724
total_nodes_created += 1;

src/mcp/server.rs

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -928,6 +928,19 @@ impl McpServer {
928928
let language_filter = args["language"].as_str();
929929
let node_type_filter = args["node_type"].as_str();
930930

931+
// Query quality factor: penalize vague/short queries so relevance scores
932+
// reflect actual match quality, not just relative rank position.
933+
let meaningful_tokens: Vec<&str> = query.split_whitespace()
934+
.filter(|w| w.len() > 1 || w.chars().all(|c| c.is_uppercase()))
935+
.collect();
936+
let query_quality = match meaningful_tokens.len() {
937+
0 => 0.3,
938+
1 if meaningful_tokens[0].len() <= 2 => 0.4,
939+
1 => 0.7,
940+
2 => 0.85,
941+
_ => 1.0,
942+
};
943+
931944
// Lazy model loading: pick up model if downloaded in background
932945
self.try_lazy_load_model();
933946

@@ -1022,9 +1035,13 @@ impl McpServer {
10221035
} else {
10231036
node.code_content.clone()
10241037
};
1025-
// Normalize RRF score to 0.0–1.0 range for readability
1038+
// Normalize RRF score to 0.0–1.0 range, then apply query quality factor
1039+
// so short/vague queries produce lower relevance scores
10261040
let score = if let Some(max_score) = fused.first().map(|f| f.score) {
1027-
if max_score > 0.0 { (r.score / max_score * 100.0).round() / 100.0 } else { 0.0 }
1041+
if max_score > 0.0 {
1042+
let normalized = r.score / max_score;
1043+
(normalized * query_quality * 100.0).round() / 100.0
1044+
} else { 0.0 }
10281045
} else { 0.0 };
10291046
results.push(json!({
10301047
"node_id": node.id,
@@ -1911,7 +1928,11 @@ impl McpServer {
19111928
"file": file_path,
19121929
"depends_on": outgoing,
19131930
"depended_by": incoming,
1914-
"summary": format!("{} depends on {} files, {} files depend on it", file_path, outgoing.len(), incoming.len())
1931+
"summary": format!("{} depends on {} file{}, {} file{} depend{} on it",
1932+
file_path,
1933+
outgoing.len(), if outgoing.len() == 1 { "" } else { "s" },
1934+
incoming.len(), if incoming.len() == 1 { "" } else { "s" },
1935+
if incoming.len() == 1 { "s" } else { "" })
19151936
}))
19161937
}
19171938

src/parser/treesitter.rs

Lines changed: 78 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ pub struct ParsedNode {
1919
/// Full parameter text from AST, e.g. "(a: number, b: string)" — includes names and types,
2020
/// not just type annotations. Stored as-is for FTS search (users may search by param names).
2121
pub param_types: Option<String>,
22+
/// True if this node is inside a test context (#[cfg(test)], mod tests, #[test], etc.)
23+
pub is_test: bool,
2224
}
2325

2426
thread_local! {
@@ -53,32 +55,75 @@ pub fn parse_code(source: &str, language: &str) -> Result<Vec<ParsedNode>> {
5355
/// Extract nodes from a pre-parsed tree (avoids re-parsing).
5456
pub fn extract_nodes_from_tree(tree: &tree_sitter::Tree, source: &str, language: &str) -> Vec<ParsedNode> {
5557
let mut nodes = Vec::new();
56-
extract_nodes(tree.root_node(), source, language, None, &mut nodes, 0);
58+
extract_nodes(tree.root_node(), source, language, None, &mut nodes, 0, false);
5759
nodes
5860
}
5961

62+
/// Check if a node has a preceding `#[cfg(test)]` or `#[test]` attribute.
63+
fn has_test_attribute(node: &tree_sitter::Node, source: &str) -> bool {
64+
let mut sibling = node.prev_sibling();
65+
while let Some(s) = sibling {
66+
match s.kind() {
67+
"attribute_item" | "inner_attribute_item" => {
68+
let text = node_text(&s, source);
69+
if text.contains("cfg(test)") || text == "#[test]" {
70+
return true;
71+
}
72+
}
73+
"line_comment" | "block_comment" | "comment" => {}
74+
_ => break,
75+
}
76+
sibling = s.prev_sibling();
77+
}
78+
false
79+
}
80+
6081
fn extract_nodes(
6182
node: tree_sitter::Node,
6283
source: &str,
6384
language: &str,
6485
parent_class: Option<&str>,
6586
results: &mut Vec<ParsedNode>,
6687
depth: usize,
88+
in_test_context: bool,
6789
) {
6890
if depth > MAX_AST_DEPTH { return; }
6991
let kind = node.kind();
7092

93+
// Detect Rust mod items (e.g., `mod tests { ... }`)
94+
if kind == "mod_item" {
95+
let mod_name = node.child_by_field_name("name")
96+
.map(|n| node_text(&n, source).to_string());
97+
let is_test_mod = mod_name.as_deref() == Some("tests")
98+
|| has_test_attribute(&node, source);
99+
// Recurse into the module body with updated test context
100+
if let Some(body) = node.child_by_field_name("body") {
101+
for i in 0..body.named_child_count() {
102+
if let Some(child) = body.named_child(i) {
103+
extract_nodes(child, source, language, parent_class, results, depth + 1,
104+
in_test_context || is_test_mod);
105+
}
106+
}
107+
}
108+
return;
109+
}
110+
111+
// Check if this specific node has #[test] or #[cfg(test)]
112+
let node_is_test = in_test_context || has_test_attribute(&node, source);
113+
71114
match kind {
72115
// Functions: shared across TS/JS/Go (function_declaration), Python/C/C++ (function_definition)
73116
"function_declaration" | "function" => {
74-
if let Some(parsed) = extract_function_node(&node, source, "function", parent_class) {
117+
if let Some(mut parsed) = extract_function_node(&node, source, "function", parent_class) {
118+
parsed.is_test = node_is_test;
75119
results.push(parsed);
76120
}
77121
}
78122
// Python async functions
79123
"async_function_definition" => {
80124
let nt = if parent_class.is_some() { "method" } else { "function" };
81-
if let Some(parsed) = extract_function_node(&node, source, nt, parent_class) {
125+
if let Some(mut parsed) = extract_function_node(&node, source, nt, parent_class) {
126+
parsed.is_test = node_is_test;
82127
results.push(parsed);
83128
}
84129
}
@@ -100,27 +145,31 @@ fn extract_nodes(
100145
doc_comment: get_preceding_comment(&node, source),
101146
return_type: sig_info.return_type,
102147
param_types: sig_info.param_types,
148+
is_test: node_is_test,
103149
});
104150
}
105151
}
106152
} else {
107153
// Python and others: name is in "name" field
108154
let nt = if parent_class.is_some() { "method" } else { "function" };
109-
if let Some(parsed) = extract_function_node(&node, source, nt, parent_class) {
155+
if let Some(mut parsed) = extract_function_node(&node, source, nt, parent_class) {
156+
parsed.is_test = node_is_test;
110157
results.push(parsed);
111158
}
112159
}
113160
}
114161
"function_item" => {
115162
// Rust functions
116-
if let Some(parsed) = extract_function_node(&node, source, "function", parent_class) {
163+
if let Some(mut parsed) = extract_function_node(&node, source, "function", parent_class) {
164+
parsed.is_test = node_is_test;
117165
results.push(parsed);
118166
}
119167
}
120168

121169
// Arrow functions (TS/JS): covers const/let (lexical) and var (variable)
122170
"lexical_declaration" | "variable_declaration" => {
123-
if let Some(parsed) = extract_named_arrow(&node, source) {
171+
if let Some(mut parsed) = extract_named_arrow(&node, source) {
172+
parsed.is_test = node_is_test;
124173
results.push(parsed);
125174
}
126175
}
@@ -139,48 +188,50 @@ fn extract_nodes(
139188
doc_comment: get_preceding_comment(&node, source),
140189
return_type: None,
141190
param_types: None,
191+
is_test: node_is_test,
142192
});
143-
extract_children(node, source, language, Some(&name), results, depth);
193+
extract_children(node, source, language, Some(&name), results, depth, node_is_test);
144194
return;
145195
}
146196
}
147197

148198
// Methods: TS/JS (method_definition), Go/Java (method_declaration)
149199
"method_definition" | "method_declaration" => {
150-
if let Some(parsed) = extract_function_node(&node, source, "method", parent_class) {
200+
if let Some(mut parsed) = extract_function_node(&node, source, "method", parent_class) {
201+
parsed.is_test = node_is_test;
151202
results.push(parsed);
152203
}
153204
}
154205

155206
// Interfaces (TS/Java)
156207
"interface_declaration" => {
157208
if let Some(name) = get_child_by_field(&node, "name", source) {
158-
results.push(make_simple_node("interface", name.clone(), &node, source));
159-
extract_children(node, source, language, Some(&name), results, depth);
209+
results.push(make_simple_node("interface", name.clone(), &node, source, node_is_test));
210+
extract_children(node, source, language, Some(&name), results, depth, node_is_test);
160211
return;
161212
}
162213
}
163214

164215
// TS type aliases: type Foo = ...
165216
"type_alias_declaration" => {
166217
if let Some(name) = get_child_by_field(&node, "name", source) {
167-
results.push(make_simple_node("type", name, &node, source));
218+
results.push(make_simple_node("type", name, &node, source, node_is_test));
168219
}
169220
}
170221

171222
// Java enums
172223
"enum_declaration" => {
173224
if let Some(name) = get_child_by_field(&node, "name", source) {
174-
results.push(make_simple_node("enum", name, &node, source));
225+
results.push(make_simple_node("enum", name, &node, source, node_is_test));
175226
}
176227
}
177228

178229
// C++ class/struct
179230
"class_specifier" | "struct_specifier" => {
180231
if let Some(name) = get_child_by_field(&node, "name", source) {
181232
let nt = if kind == "class_specifier" { "class" } else { "struct" };
182-
results.push(make_simple_node(nt, name.clone(), &node, source));
183-
extract_children(node, source, language, Some(&name), results, depth);
233+
results.push(make_simple_node(nt, name.clone(), &node, source, node_is_test));
234+
extract_children(node, source, language, Some(&name), results, depth, node_is_test);
184235
return;
185236
}
186237
}
@@ -211,6 +262,7 @@ fn extract_nodes(
211262
doc_comment: get_preceding_comment(&child, source),
212263
return_type: None,
213264
param_types: None,
265+
is_test: node_is_test,
214266
});
215267
}
216268
}
@@ -221,25 +273,25 @@ fn extract_nodes(
221273
// Rust-specific
222274
"struct_item" => {
223275
if let Some(name) = get_child_by_field(&node, "name", source) {
224-
results.push(make_simple_node("struct", name, &node, source));
276+
results.push(make_simple_node("struct", name, &node, source, node_is_test));
225277
}
226278
}
227279
"enum_item" => {
228280
if let Some(name) = get_child_by_field(&node, "name", source) {
229-
results.push(make_simple_node("enum", name, &node, source));
281+
results.push(make_simple_node("enum", name, &node, source, node_is_test));
230282
}
231283
}
232284
"impl_item" => {
233285
if let Some(type_node) = node.child_by_field_name("type") {
234286
let impl_name = node_text(&type_node, source);
235-
extract_children(node, source, language, Some(impl_name), results, depth);
287+
extract_children(node, source, language, Some(impl_name), results, depth, node_is_test);
236288
return;
237289
}
238290
}
239291
"trait_item" => {
240292
if let Some(name) = get_child_by_field(&node, "name", source) {
241-
results.push(make_simple_node("interface", name.clone(), &node, source));
242-
extract_children(node, source, language, Some(&name), results, depth);
293+
results.push(make_simple_node("interface", name.clone(), &node, source, node_is_test));
294+
extract_children(node, source, language, Some(&name), results, depth, node_is_test);
243295
return;
244296
}
245297
}
@@ -248,7 +300,7 @@ fn extract_nodes(
248300
}
249301

250302
// Recurse into children
251-
extract_children(node, source, language, parent_class, results, depth);
303+
extract_children(node, source, language, parent_class, results, depth, node_is_test);
252304
}
253305

254306
fn extract_children(
@@ -258,10 +310,11 @@ fn extract_children(
258310
parent_class: Option<&str>,
259311
results: &mut Vec<ParsedNode>,
260312
depth: usize,
313+
in_test_context: bool,
261314
) {
262315
for i in 0..node.named_child_count() {
263316
if let Some(child) = node.named_child(i) {
264-
extract_nodes(child, source, language, parent_class, results, depth + 1);
317+
extract_nodes(child, source, language, parent_class, results, depth + 1, in_test_context);
265318
}
266319
}
267320
}
@@ -280,7 +333,7 @@ fn truncate_code_content(content: &str) -> Cow<'_, str> {
280333
}
281334
}
282335

283-
fn make_simple_node(node_type: &str, name: String, node: &tree_sitter::Node, source: &str) -> ParsedNode {
336+
fn make_simple_node(node_type: &str, name: String, node: &tree_sitter::Node, source: &str, is_test: bool) -> ParsedNode {
284337
ParsedNode {
285338
node_type: node_type.into(),
286339
name: name.clone(),
@@ -292,6 +345,7 @@ fn make_simple_node(node_type: &str, name: String, node: &tree_sitter::Node, sou
292345
doc_comment: get_preceding_comment(node, source),
293346
return_type: None,
294347
param_types: None,
348+
is_test,
295349
}
296350
}
297351

@@ -319,6 +373,7 @@ fn extract_function_node(
319373
doc_comment: get_preceding_comment(node, source),
320374
return_type: sig_info.return_type,
321375
param_types: sig_info.param_types,
376+
is_test: false,
322377
})
323378
}
324379

@@ -345,6 +400,7 @@ fn extract_named_arrow(node: &tree_sitter::Node, source: &str) -> Option<ParsedN
345400
doc_comment: get_preceding_comment(node, source),
346401
return_type: sig_info.return_type,
347402
param_types: sig_info.param_types,
403+
is_test: false,
348404
});
349405
}
350406
}

0 commit comments

Comments
 (0)