feat: add CLI fallback for MCP calls in team commands

- Implemented CLI fallback using `ccw team` for various team command operations in `execute.md`, `plan.md`, `review.md`, `spec-analyst.md`, `spec-coordinate.md`, `spec-discuss.md`, `spec-reviewer.md`, `spec-writer.md`, and `test.md`.
- Updated command generation templates to include CLI fallback examples.
- Enhanced validation checks to ensure CLI fallback sections are present.
- Added quality standards for CLI fallback in team command design.
- Introduced a new `GlobalGraphExpander` class for expanding search results with cross-directory relationships.
- Added tests for `GlobalGraphExpander` to verify functionality and score decay factors.
This commit is contained in:
catlog22
2026-02-13 12:05:48 +08:00
parent ac32b28c7b
commit 6054a01b8f
19 changed files with 804 additions and 7 deletions

View File

@@ -55,6 +55,20 @@ mcp__ccw-tools__team_msg({ operation: "log", team: teamName, from: "executor", t
mcp__ccw-tools__team_msg({ operation: "log", team: teamName, from: "executor", to: "coordinator", type: "error", summary: "plan.json路径无效, 无法加载实现计划" })
```
### CLI 回退
`mcp__ccw-tools__team_msg` MCP 不可用时,使用 `ccw team` CLI 作为等效回退:
```javascript
// 回退: 将 MCP 调用替换为 Bash CLI参数一一对应
Bash(`ccw team log --team "${teamName}" --from "executor" --to "coordinator" --type "impl_complete" --summary "IMPL-001完成: 5个文件变更" --json`)
// 带 data 参数
Bash(`ccw team log --team "${teamName}" --from "executor" --to "coordinator" --type "impl_progress" --summary "Batch 1/3 完成" --data '{"batch":1,"total":3}' --json`)
```
**参数映射**: `team_msg(params)``ccw team log --team <team> --from executor --to coordinator --type <type> --summary "<text>" [--ref <path>] [--data '<json>'] [--json]`
## Execution Process
```

View File

@@ -55,6 +55,17 @@ mcp__ccw-tools__team_msg({ operation: "log", team: teamName, from: "planner", to
mcp__ccw-tools__team_msg({ operation: "log", team: teamName, from: "planner", to: "coordinator", type: "error", summary: "plan-overview-base-schema.json 未找到, 使用默认结构" })
```
### CLI 回退
`mcp__ccw-tools__team_msg` MCP 不可用时,使用 `ccw team` CLI 作为等效回退:
```javascript
// 回退: 将 MCP 调用替换为 Bash CLI参数一一对应
Bash(`ccw team log --team "${teamName}" --from "planner" --to "coordinator" --type "plan_ready" --summary "Plan就绪: 3个task" --ref "${sessionFolder}/plan.json" --json`)
```
**参数映射**: `team_msg(params)``ccw team log --team <team> --from planner --to coordinator --type <type> --summary "<text>" [--ref <path>] [--data '<json>'] [--json]`
## Execution Process
```

View File

@@ -58,6 +58,17 @@ mcp__ccw-tools__team_msg({ operation: "log", team: teamName, from: "tester", to:
mcp__ccw-tools__team_msg({ operation: "log", team: teamName, from: "tester", to: "coordinator", type: "error", summary: "plan.json未找到, 无法进行需求验证" })
```
### CLI 回退
`mcp__ccw-tools__team_msg` MCP 不可用时,使用 `ccw team` CLI 作为等效回退:
```javascript
// 回退: 将 MCP 调用替换为 Bash CLI参数一一对应
Bash(`ccw team log --team "${teamName}" --from "tester" --to "coordinator" --type "review_result" --summary "REVIEW-001: APPROVE, 2 medium findings" --data '{"verdict":"APPROVE","critical":0}' --json`)
```
**参数映射**: `team_msg(params)``ccw team log --team <team> --from tester --to coordinator --type <type> --summary "<text>" [--data '<json>'] [--json]`
## Execution Process
```

View File

@@ -54,6 +54,17 @@ mcp__ccw-tools__team_msg({ operation: "log", team: teamName, from: "spec-analyst
mcp__ccw-tools__team_msg({ operation: "log", team: teamName, from: "spec-analyst", to: "coordinator", type: "error", summary: "代码库探索失败: 项目根目录无法识别" })
```
### CLI 回退
`mcp__ccw-tools__team_msg` MCP 不可用时,使用 `ccw team` CLI 作为等效回退:
```javascript
// 回退: 将 MCP 调用替换为 Bash CLI参数一一对应
Bash(`ccw team log --team "${teamName}" --from "spec-analyst" --to "coordinator" --type "research_ready" --summary "研究完成: 5个探索维度" --ref "${sessionFolder}/discovery-context.json" --json`)
```
**参数映射**: `team_msg(params)``ccw team log --team <team> --from spec-analyst --to coordinator --type <type> --summary "<text>" [--ref <path>] [--data '<json>'] [--json]`
## Execution Process
```

View File

@@ -31,6 +31,26 @@ mcp__ccw-tools__team_msg({ operation: "status", team: teamName })
**日志位置**: `.workflow/.team-msg/{team-name}/messages.jsonl`
**消息类型**: `research_ready | research_progress | draft_ready | draft_revision | quality_result | discussion_ready | discussion_blocked | fix_required | error | shutdown`
### CLI 回退
`mcp__ccw-tools__team_msg` MCP 不可用时,使用 `ccw team` CLI 作为等效回退:
```javascript
// 回退: 将 MCP 调用替换为 Bash CLI参数一一对应
// log
Bash(`ccw team log --team "${teamName}" --from "coordinator" --to "spec-analyst" --type "plan_approved" --summary "研究结果已确认" --json`)
// list
Bash(`ccw team list --team "${teamName}" --last 10 --json`)
// list (带过滤)
Bash(`ccw team list --team "${teamName}" --from "spec-discuss" --last 5 --json`)
// status
Bash(`ccw team status --team "${teamName}" --json`)
// read
Bash(`ccw team read --team "${teamName}" --id "MSG-003" --json`)
```
**参数映射**: `team_msg(params)``ccw team <operation> --team <team> [--from/--to/--type/--summary/--ref/--data/--id/--last] [--json]`
## Pipeline
```

View File

@@ -56,6 +56,20 @@ mcp__ccw-tools__team_msg({ operation: "log", team: teamName, from: "spec-discuss
mcp__ccw-tools__team_msg({ operation: "log", team: teamName, from: "spec-discuss", to: "coordinator", type: "error", summary: "DISCUSS-001: 找不到 discovery-context.json" })
```
### CLI 回退
`mcp__ccw-tools__team_msg` MCP 不可用时,使用 `ccw team` CLI 作为等效回退:
```javascript
// 回退: 将 MCP 调用替换为 Bash CLI参数一一对应
Bash(`ccw team log --team "${teamName}" --from "spec-discuss" --to "coordinator" --type "discussion_ready" --summary "DISCUSS-002: 共识达成, 3个改进建议" --ref "${sessionFolder}/discussions/discuss-002-brief.md" --json`)
// 带 data 参数(讨论阻塞时)
Bash(`ccw team log --team "${teamName}" --from "spec-discuss" --to "coordinator" --type "discussion_blocked" --summary "技术选型分歧" --data '{"reason":"微服务 vs 单体","options":[{"label":"微服务"},{"label":"单体"}]}' --json`)
```
**参数映射**: `team_msg(params)``ccw team log --team <team> --from spec-discuss --to coordinator --type <type> --summary "<text>" [--ref <path>] [--data '<json>'] [--json]`
## 讨论维度模型
每个讨论轮次从4个视角进行结构化分析

View File

@@ -55,6 +55,17 @@ mcp__ccw-tools__team_msg({ operation: "log", team: teamName, from: "spec-reviewe
mcp__ccw-tools__team_msg({ operation: "log", team: teamName, from: "spec-reviewer", to: "coordinator", type: "fix_required", summary: "质量 FAIL: 55分, 缺少架构 ADR + PRD 验收标准不可测", data: { gate: "FAIL", score: 55, issues: ["missing ADRs", "untestable AC"] } })
```
### CLI 回退
`mcp__ccw-tools__team_msg` MCP 不可用时,使用 `ccw team` CLI 作为等效回退:
```javascript
// 回退: 将 MCP 调用替换为 Bash CLI参数一一对应
Bash(`ccw team log --team "${teamName}" --from "spec-reviewer" --to "coordinator" --type "quality_result" --summary "质量检查 PASS: 85分" --data '{"gate":"PASS","score":85}' --json`)
```
**参数映射**: `team_msg(params)``ccw team log --team <team> --from spec-reviewer --to coordinator --type <type> --summary "<text>" [--data '<json>'] [--json]`
## Execution Process
```

View File

@@ -55,6 +55,17 @@ mcp__ccw-tools__team_msg({ operation: "log", team: teamName, from: "spec-writer"
mcp__ccw-tools__team_msg({ operation: "log", team: teamName, from: "spec-writer", to: "coordinator", type: "error", summary: "缺少 discovery-context.json, 无法生成 Product Brief" })
```
### CLI 回退
`mcp__ccw-tools__team_msg` MCP 不可用时,使用 `ccw team` CLI 作为等效回退:
```javascript
// 回退: 将 MCP 调用替换为 Bash CLI参数一一对应
Bash(`ccw team log --team "${teamName}" --from "spec-writer" --to "coordinator" --type "draft_ready" --summary "Product Brief 完成: 8个章节" --ref "${sessionFolder}/product-brief.md" --json`)
```
**参数映射**: `team_msg(params)``ccw team log --team <team> --from spec-writer --to coordinator --type <type> --summary "<text>" [--ref <path>] [--data '<json>'] [--json]`
## Execution Process
```

View File

@@ -59,6 +59,17 @@ mcp__ccw-tools__team_msg({ operation: "log", team: teamName, from: "tester", to:
mcp__ccw-tools__team_msg({ operation: "log", team: teamName, from: "tester", to: "coordinator", type: "error", summary: "jest命令未找到, 请确认测试框架已安装" })
```
### CLI 回退
`mcp__ccw-tools__team_msg` MCP 不可用时,使用 `ccw team` CLI 作为等效回退:
```javascript
// 回退: 将 MCP 调用替换为 Bash CLI参数一一对应
Bash(`ccw team log --team "${teamName}" --from "tester" --to "coordinator" --type "test_result" --summary "TEST-001通过: 98% pass rate" --data '{"passRate":98,"iterations":3}' --json`)
```
**参数映射**: `team_msg(params)``ccw team log --team <team> --from tester --to coordinator --type <type> --summary "<text>" [--data '<json>'] [--json]`
## Execution Process
```

View File

@@ -68,7 +68,18 @@ ${config.message_types.filter(mt => mt.type !== 'error').map(mt =>
`// ${mt.trigger}
mcp__ccw-tools__team_msg({ operation: "log", team: teamName, from: "${config.role_name}", to: "coordinator", type: "${mt.type}", summary: "${mt.trigger}" })`
).join('\n\n')}
\`\`\``
\`\`\`
### CLI 回退
\`mcp__ccw-tools__team_msg\` MCP 不可用时,使用 \`ccw team\` CLI 作为等效回退:
\\\`\\\`\\\`javascript
// 回退: 将 MCP 调用替换为 Bash CLI参数一一对应
Bash(\\\`ccw team log --team "\${teamName}" --from "${config.role_name}" --to "coordinator" --type "${config.message_types[0]?.type || 'result'}" --summary "<摘要>" --json\\\`)
\\\`\\\`\\\`
**参数映射**: \`team_msg(params)\`\`ccw team log --team <team> --from ${config.role_name} --to coordinator --type <type> --summary "<text>" [--ref <path>] [--data '<json>'] [--json]\``
```
### Step 4: Generate Phase Implementations

View File

@@ -42,6 +42,8 @@ const requiredSections = [
{ name: "TaskGet Usage", pattern: /TaskGet/ },
{ name: "TaskUpdate Usage", pattern: /TaskUpdate/ },
{ name: "SendMessage to Coordinator", pattern: /SendMessage.*coordinator/i },
{ name: "CLI Fallback Section", pattern: /CLI 回退|CLI Fallback/ },
{ name: "ccw team CLI Example", pattern: /ccw team log/ },
{ name: "Error Handling Table", pattern: /## Error Handling/ },
{ name: "Implementation Section", pattern: /## Implementation/ }
]
@@ -110,6 +112,14 @@ const patternChecks = [
return command.includes('idle') || command.includes('return')
},
severity: "medium"
},
{
name: "CLI Fallback Present",
check: () => {
return (command.includes('CLI 回退') || command.includes('CLI Fallback')) &&
command.includes('ccw team log')
},
severity: "high"
}
]

View File

@@ -50,6 +50,7 @@ Quality assessment criteria for generated team command .md files.
**Infrastructure Pattern Checklist:**
- [ ] Pattern 1: Message bus - team_msg before every SendMessage
- [ ] Pattern 1b: CLI fallback - `ccw team` CLI fallback section with parameter mapping
- [ ] Pattern 2: YAML front matter - all fields present, group: team
- [ ] Pattern 3: Task lifecycle - TaskList/Get/Update flow
- [ ] Pattern 4: Five-phase structure - all 5 phases present
@@ -114,6 +115,7 @@ Quality assessment criteria for generated team command .md files.
- Missing error handling table
- Incomplete Phase implementation (skeleton only)
- Missing team_msg before some SendMessage calls
- Missing CLI fallback section (`### CLI 回退` with `ccw team` examples)
- No complexity-adaptive routing when role is complex
### Info (Nice to Have)

View File

@@ -105,22 +105,47 @@ When designing a new role, define role-specific message types following the conv
- `{action}_complete` - Work phase finished
- `{action}_progress` - Intermediate progress update
### CLI Fallback
When `mcp__ccw-tools__team_msg` MCP is unavailable, use `ccw team` CLI as equivalent fallback:
```javascript
// Fallback: Replace MCP call with Bash CLI (parameters map 1:1)
Bash(`ccw team log --team "${teamName}" --from "<role>" --to "coordinator" --type "<type>" --summary "<summary>" [--ref <path>] [--data '<json>'] --json`)
```
**Parameter mapping**: `team_msg(params)``ccw team <operation> --team <team> [--from/--to/--type/--summary/--ref/--data/--id/--last] [--json]`
**Coordinator** uses all 4 operations: `log`, `list`, `status`, `read`
**Teammates** primarily use: `log`
### Message Bus Section Template
```markdown
## Message Bus
## 消息总线
Every SendMessage **before**, must call `mcp__ccw-tools__team_msg` to log:
每次 SendMessage ****,必须调用 `mcp__ccw-tools__team_msg` 记录消息:
\`\`\`javascript
mcp__ccw-tools__team_msg({ operation: "log", team: teamName, from: "<role>", to: "coordinator", type: "<type>", summary: "<summary>" })
\`\`\`
### Supported Message Types
### 支持的 Message Types
| Type | Direction | Trigger | Description |
|------|-----------|---------|-------------|
| `<type>` | <role> -> coordinator | <when> | <what> |
| Type | 方向 | 触发时机 | 说明 |
|------|------|----------|------|
| `<type>` | <role> coordinator | <when> | <what> |
### CLI 回退
`mcp__ccw-tools__team_msg` MCP 不可用时,使用 `ccw team` CLI 作为等效回退:
\`\`\`javascript
// 回退: 将 MCP 调用替换为 Bash CLI参数一一对应
Bash(\`ccw team log --team "${teamName}" --from "<role>" --to "coordinator" --type "<type>" --summary "<summary>" --json\`)
\`\`\`
**参数映射**: `team_msg(params)``ccw team log --team <team> --from <role> --to coordinator --type <type> --summary "<text>" [--ref <path>] [--data '<json>'] [--json]`
```
---

View File

@@ -71,6 +71,17 @@ mcp__ccw-tools__team_msg({ operation: "log", team: teamName, from: "{{../role_na
{{/each}}
\`\`\`
### CLI 回退
`mcp__ccw-tools__team_msg` MCP 不可用时,使用 `ccw team` CLI 作为等效回退:
\`\`\`javascript
// 回退: 将 MCP 调用替换为 Bash CLI参数一一对应
Bash(\`ccw team log --team "${teamName}" --from "{{role_name}}" --to "coordinator" --type "{{primary_message_type}}" --summary "<摘要>" --json\`)
\`\`\`
**参数映射**: `team_msg(params)``ccw team log --team <team> --from {{role_name}} --to coordinator --type <type> --summary "<text>" [--ref <path>] [--data '<json>'] [--json]`
## Execution Process
\`\`\`

View File

@@ -146,6 +146,11 @@ class Config:
staged_coarse_k: int = 200 # Number of coarse candidates from Stage 1 binary search
staged_lsp_depth: int = 2 # LSP relationship expansion depth in Stage 2
staged_stage2_mode: str = "precomputed" # "precomputed" (graph_neighbors) | "realtime" (LSP)
# Static graph configuration (write relationships to global index during build)
static_graph_enabled: bool = False
static_graph_relationship_types: List[str] = field(default_factory=lambda: ["imports", "inherits"])
staged_realtime_lsp_timeout_s: float = 30.0 # Max time budget for realtime LSP expansion
staged_realtime_lsp_depth: int = 1 # BFS depth for realtime LSP expansion
staged_realtime_lsp_max_nodes: int = 50 # Node cap for realtime graph expansion

View File

@@ -5,6 +5,7 @@ from .chain_search import (
ChainSearchResult,
quick_search,
)
from .global_graph_expander import GlobalGraphExpander
# Clustering availability flag (lazy import pattern)
CLUSTERING_AVAILABLE = False
@@ -46,6 +47,7 @@ __all__ = [
"SearchStats",
"ChainSearchResult",
"quick_search",
"GlobalGraphExpander",
# Clustering
"CLUSTERING_AVAILABLE",
"check_clustering_available",

View File

@@ -0,0 +1,250 @@
"""Global graph expansion for search results using cross-directory relationships.
Expands top search results with related symbols by querying the global_relationships
table in GlobalSymbolIndex, enabling project-wide code graph traversal.
"""
from __future__ import annotations
import logging
import sqlite3
from typing import Dict, List, Optional, Sequence, Tuple
from codexlens.config import Config
from codexlens.entities import SearchResult
from codexlens.storage.global_index import GlobalSymbolIndex
logger = logging.getLogger(__name__)
# Score decay factors by relationship type.
# INHERITS has highest factor (strongest semantic link),
# IMPORTS next (explicit dependency), CALLS lowest (may be indirect).
DECAY_FACTORS: Dict[str, float] = {
"imports": 0.4,
"inherits": 0.5,
"calls": 0.3,
}
DEFAULT_DECAY = 0.3
class GlobalGraphExpander:
"""Expands search results with cross-directory related symbols from the global graph."""
def __init__(
self,
global_index: GlobalSymbolIndex,
*,
config: Optional[Config] = None,
) -> None:
self._global_index = global_index
self._config = config
self._logger = logging.getLogger(__name__)
def expand(
self,
results: Sequence[SearchResult],
*,
top_n: int = 10,
max_related: int = 50,
) -> List[SearchResult]:
"""Expand top-N results with related symbols from global relationships.
Args:
results: Base ranked results from Stage 1.
top_n: Only expand the top-N base results.
max_related: Maximum related results to return.
Returns:
List of related SearchResult objects (does NOT include the input results).
"""
if not results:
return []
# 1. Extract symbol names from top results
symbols_with_scores = self._resolve_symbols(results, top_n)
if not symbols_with_scores:
return []
symbol_names = [s[0] for s in symbols_with_scores]
base_scores = {s[0]: s[1] for s in symbols_with_scores}
# 2. Query global relationships
relationships = self._query_relationships(symbol_names, limit=max_related * 3)
if not relationships:
return []
# 3. Build expanded results with score decay
expanded = self._build_expanded_results(
relationships, base_scores, max_related
)
# 4. Deduplicate against input results
input_keys: set[Tuple[str, Optional[str], Optional[int]]] = set()
for r in results:
input_keys.add((r.path, r.symbol_name, r.start_line))
deduped: List[SearchResult] = []
seen: set[Tuple[str, Optional[str], Optional[int]]] = set()
for r in expanded:
key = (r.path, r.symbol_name, r.start_line)
if key not in input_keys and key not in seen:
seen.add(key)
deduped.append(r)
return deduped[:max_related]
def _resolve_symbols(
self,
results: Sequence[SearchResult],
top_n: int,
) -> List[Tuple[str, float]]:
"""Extract (symbol_name, score) pairs from top results."""
symbols: List[Tuple[str, float]] = []
seen: set[str] = set()
for r in list(results)[:top_n]:
name = r.symbol_name
if not name or name in seen:
continue
seen.add(name)
symbols.append((name, float(r.score)))
return symbols
def _query_relationships(
self,
symbol_names: List[str],
limit: int = 150,
) -> List[sqlite3.Row]:
"""Query global_relationships for symbols."""
try:
return self._global_index.query_relationships_for_symbols(
symbol_names, limit=limit
)
except Exception as exc:
self._logger.debug("Global graph query failed: %s", exc)
return []
def _resolve_target_to_file(
self,
target_qualified_name: str,
) -> Optional[Tuple[str, int, int]]:
"""Resolve target_qualified_name to (file_path, start_line, end_line).
Tries ``file_path::symbol_name`` format first, then falls back to
symbol name search in the global index.
"""
# Format: "file_path::symbol_name"
if "::" in target_qualified_name:
parts = target_qualified_name.split("::", 1)
target_file = parts[0]
target_symbol = parts[1]
try:
symbols = self._global_index.search(target_symbol, limit=5)
for sym in symbols:
if sym.file and str(sym.file) == target_file:
return (
target_file,
sym.range[0] if sym.range else 1,
sym.range[1] if sym.range else 1,
)
# File path known but line info unavailable
return (target_file, 1, 1)
except Exception:
return (target_file, 1, 1)
# Plain symbol name (possibly dot-qualified like "mod.ClassName")
try:
leaf_name = target_qualified_name.rsplit(".", 1)[-1]
symbols = self._global_index.search(leaf_name, limit=5)
if symbols:
sym = symbols[0]
file_path = str(sym.file) if sym.file else None
if file_path:
return (
file_path,
sym.range[0] if sym.range else 1,
sym.range[1] if sym.range else 1,
)
except Exception:
pass
return None
def _build_expanded_results(
self,
relationships: List[sqlite3.Row],
base_scores: Dict[str, float],
max_related: int,
) -> List[SearchResult]:
"""Build SearchResult list from relationships with score decay."""
results: List[SearchResult] = []
for rel in relationships:
source_file = rel["source_file"]
source_symbol = rel["source_symbol"]
target_qname = rel["target_qualified_name"]
rel_type = rel["relationship_type"]
source_line = rel["source_line"]
# Determine base score from the matched symbol
base_score = base_scores.get(source_symbol, 0.0)
if base_score == 0.0:
# Try matching against the target leaf name
leaf = target_qname.rsplit(".", 1)[-1] if "." in target_qname else target_qname
if "::" in leaf:
leaf = leaf.split("::")[-1]
base_score = base_scores.get(leaf, 0.0)
if base_score == 0.0:
base_score = 0.5 # Default when no match found
# Apply decay factor
decay = DECAY_FACTORS.get(rel_type, DEFAULT_DECAY)
score = base_score * decay
# Try to resolve target to file for a richer result
target_info = self._resolve_target_to_file(target_qname)
if target_info:
t_file, t_start, t_end = target_info
results.append(SearchResult(
path=t_file,
score=score,
excerpt=None,
content=None,
start_line=t_start,
end_line=t_end,
symbol_name=(
target_qname.split("::")[-1]
if "::" in target_qname
else target_qname.rsplit(".", 1)[-1]
),
symbol_kind=None,
metadata={
"source": "static_graph",
"relationship_type": rel_type,
"from_symbol": source_symbol,
"from_file": source_file,
},
))
else:
# Use source file as fallback (we know the source exists)
results.append(SearchResult(
path=source_file,
score=score * 0.8, # Slight penalty for unresolved target
excerpt=None,
content=None,
start_line=source_line,
end_line=source_line,
symbol_name=source_symbol,
symbol_kind=None,
metadata={
"source": "static_graph",
"relationship_type": rel_type,
"target_qualified_name": target_qname,
},
))
if len(results) >= max_related:
break
# Sort by score descending
results.sort(key=lambda r: r.score, reverse=True)
return results

View File

@@ -517,6 +517,8 @@ class IndexTreeBuilder:
"supported_languages": self.config.supported_languages,
"parsing_rules": self.config.parsing_rules,
"global_symbol_index_enabled": self.config.global_symbol_index_enabled,
"static_graph_enabled": self.config.static_graph_enabled,
"static_graph_relationship_types": self.config.static_graph_relationship_types,
}
worker_args = [
@@ -627,6 +629,27 @@ class IndexTreeBuilder:
relationships=indexed_file.relationships,
)
# Write global relationships if enabled
if (
self.config.static_graph_enabled
and global_index is not None
and indexed_file.relationships
):
try:
filtered_rels = [
r for r in indexed_file.relationships
if r.relationship_type.value in self.config.static_graph_relationship_types
]
if filtered_rels:
global_index.update_file_relationships(
file_path, filtered_rels
)
except Exception as rel_exc:
self.logger.warning(
"Failed to write global relationships for %s: %s",
file_path, rel_exc,
)
files_count += 1
symbols_count += len(indexed_file.symbols)
@@ -959,6 +982,8 @@ def _build_dir_worker(args: tuple) -> DirBuildResult:
supported_languages=config_dict["supported_languages"],
parsing_rules=config_dict["parsing_rules"],
global_symbol_index_enabled=bool(config_dict.get("global_symbol_index_enabled", True)),
static_graph_enabled=bool(config_dict.get("static_graph_enabled", False)),
static_graph_relationship_types=list(config_dict.get("static_graph_relationship_types", ["imports", "inherits"])),
)
parser_factory = ParserFactory(config)
@@ -1008,6 +1033,25 @@ def _build_dir_worker(args: tuple) -> DirBuildResult:
relationships=indexed_file.relationships,
)
# Write global relationships if enabled
if (
config.static_graph_enabled
and global_index is not None
and indexed_file.relationships
):
try:
allowed_types = config.static_graph_relationship_types
filtered_rels = [
r for r in indexed_file.relationships
if r.relationship_type.value in allowed_types
]
if filtered_rels:
global_index.update_file_relationships(
item, filtered_rels
)
except Exception:
pass # Don't block indexing
files_count += 1
symbols_count += len(indexed_file.symbols)

View File

@@ -0,0 +1,323 @@
"""Tests for GlobalGraphExpander."""
import tempfile
from pathlib import Path
import pytest
from codexlens.entities import (
CodeRelationship,
RelationshipType,
SearchResult,
Symbol,
)
from codexlens.search.global_graph_expander import (
DECAY_FACTORS,
DEFAULT_DECAY,
GlobalGraphExpander,
)
from codexlens.storage.global_index import GlobalSymbolIndex
@pytest.fixture()
def temp_dir():
tmpdir = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
yield Path(tmpdir.name)
try:
tmpdir.cleanup()
except (PermissionError, OSError):
pass
def _setup_global_index(root: Path) -> GlobalSymbolIndex:
"""Create a GlobalSymbolIndex with test symbols and relationships."""
db_path = root / "test_global.db"
gsi = GlobalSymbolIndex(db_path, project_id=1)
gsi.initialize()
# Files in different directories (cross-directory scenario)
file_a = str((root / "pkg_a" / "module_a.py").resolve())
file_b = str((root / "pkg_b" / "module_b.py").resolve())
file_c = str((root / "pkg_c" / "module_c.py").resolve())
index_path = str((root / "indexes" / "_index.db").resolve())
symbols_a = [
Symbol(name="ClassA", kind="class", range=(1, 20), file=file_a),
Symbol(name="func_a", kind="function", range=(22, 30), file=file_a),
]
symbols_b = [
Symbol(name="ClassB", kind="class", range=(1, 15), file=file_b),
]
symbols_c = [
Symbol(name="helper_c", kind="function", range=(1, 10), file=file_c),
]
gsi.update_file_symbols(file_a, symbols_a, index_path=index_path)
gsi.update_file_symbols(file_b, symbols_b, index_path=index_path)
gsi.update_file_symbols(file_c, symbols_c, index_path=index_path)
# Relationships:
# ClassA --imports--> ClassB (cross-directory)
# ClassA --calls--> helper_c (cross-directory)
# ClassB --inherits--> ClassA (cross-directory)
relationships_a = [
CodeRelationship(
source_symbol="ClassA",
target_symbol="ClassB",
relationship_type=RelationshipType.IMPORTS,
source_file=file_a,
target_file=file_b,
source_line=2,
),
CodeRelationship(
source_symbol="ClassA",
target_symbol="helper_c",
relationship_type=RelationshipType.CALL,
source_file=file_a,
target_file=file_c,
source_line=10,
),
]
relationships_b = [
CodeRelationship(
source_symbol="ClassB",
target_symbol="ClassA",
relationship_type=RelationshipType.INHERITS,
source_file=file_b,
target_file=file_a,
source_line=1,
),
]
gsi.update_file_relationships(file_a, relationships_a)
gsi.update_file_relationships(file_b, relationships_b)
return gsi
def test_expand_returns_related_results(temp_dir: Path) -> None:
"""expand() should return related symbols from global relationships."""
gsi = _setup_global_index(temp_dir)
try:
expander = GlobalGraphExpander(gsi)
file_a = str((temp_dir / "pkg_a" / "module_a.py").resolve())
base_results = [
SearchResult(
path=file_a,
score=1.0,
excerpt=None,
content=None,
start_line=1,
end_line=20,
symbol_name="ClassA",
symbol_kind="class",
),
]
related = expander.expand(base_results, top_n=10, max_related=50)
assert len(related) > 0
# All results should have static_graph source metadata
for r in related:
assert r.metadata.get("source") == "static_graph"
# Should find ClassB and/or helper_c as related symbols
related_symbols = {r.symbol_name for r in related}
assert len(related_symbols) > 0
finally:
gsi.close()
def test_score_decay_by_relationship_type(temp_dir: Path) -> None:
"""Score decay factors should be: IMPORTS=0.4, INHERITS=0.5, CALLS=0.3."""
# Verify the constants
assert DECAY_FACTORS["imports"] == 0.4
assert DECAY_FACTORS["inherits"] == 0.5
assert DECAY_FACTORS["calls"] == 0.3
assert DEFAULT_DECAY == 0.3
gsi = _setup_global_index(temp_dir)
try:
expander = GlobalGraphExpander(gsi)
file_a = str((temp_dir / "pkg_a" / "module_a.py").resolve())
base_results = [
SearchResult(
path=file_a,
score=1.0,
excerpt=None,
content=None,
start_line=1,
end_line=20,
symbol_name="ClassA",
symbol_kind="class",
),
]
related = expander.expand(base_results, top_n=10, max_related=50)
# Check that scores use decay factors
for r in related:
rel_type = r.metadata.get("relationship_type")
if rel_type:
expected_decay = DECAY_FACTORS.get(rel_type, DEFAULT_DECAY)
# Score should be base_score * decay (possibly * 0.8 for unresolved)
assert r.score <= 1.0 * expected_decay + 0.01
assert r.score > 0.0
finally:
gsi.close()
def test_expand_with_no_relationships_returns_empty(temp_dir: Path) -> None:
"""expand() should return empty list when no relationships exist."""
db_path = temp_dir / "empty_global.db"
gsi = GlobalSymbolIndex(db_path, project_id=1)
gsi.initialize()
try:
# Add a symbol but no relationships
file_x = str((temp_dir / "isolated.py").resolve())
index_path = str((temp_dir / "idx.db").resolve())
gsi.update_file_symbols(
file_x,
[Symbol(name="IsolatedFunc", kind="function", range=(1, 5), file=file_x)],
index_path=index_path,
)
expander = GlobalGraphExpander(gsi)
base_results = [
SearchResult(
path=file_x,
score=0.9,
excerpt=None,
content=None,
start_line=1,
end_line=5,
symbol_name="IsolatedFunc",
symbol_kind="function",
),
]
related = expander.expand(base_results, top_n=10, max_related=50)
assert related == []
finally:
gsi.close()
def test_expand_deduplicates_against_input(temp_dir: Path) -> None:
"""expand() should not include results already present in input."""
gsi = _setup_global_index(temp_dir)
try:
expander = GlobalGraphExpander(gsi)
file_a = str((temp_dir / "pkg_a" / "module_a.py").resolve())
file_b = str((temp_dir / "pkg_b" / "module_b.py").resolve())
# Include both ClassA and ClassB in input - ClassB should be deduplicated
base_results = [
SearchResult(
path=file_a,
score=1.0,
excerpt=None,
content=None,
start_line=1,
end_line=20,
symbol_name="ClassA",
symbol_kind="class",
),
SearchResult(
path=file_b,
score=0.8,
excerpt=None,
content=None,
start_line=1,
end_line=15,
symbol_name="ClassB",
symbol_kind="class",
),
]
related = expander.expand(base_results, top_n=10, max_related=50)
# No related result should match (path, symbol_name, start_line)
# of any input result
input_keys = {(r.path, r.symbol_name, r.start_line) for r in base_results}
for r in related:
assert (r.path, r.symbol_name, r.start_line) not in input_keys
finally:
gsi.close()
def test_resolve_target_with_double_colon_format(temp_dir: Path) -> None:
"""_resolve_target_to_file should handle 'file_path::symbol_name' format."""
gsi = _setup_global_index(temp_dir)
try:
expander = GlobalGraphExpander(gsi)
file_b = str((temp_dir / "pkg_b" / "module_b.py").resolve())
target_qname = f"{file_b}::ClassB"
result = expander._resolve_target_to_file(target_qname)
assert result is not None
resolved_file, start_line, end_line = result
assert resolved_file == file_b
# ClassB is at range (1, 15)
assert start_line == 1
assert end_line == 15
finally:
gsi.close()
def test_resolve_target_with_dot_notation(temp_dir: Path) -> None:
"""_resolve_target_to_file should handle 'module.ClassName' dot notation."""
gsi = _setup_global_index(temp_dir)
try:
expander = GlobalGraphExpander(gsi)
# "pkg.ClassB" - leaf name "ClassB" should be found via search
result = expander._resolve_target_to_file("pkg.ClassB")
assert result is not None
resolved_file, start_line, end_line = result
# Should resolve to ClassB's file
file_b = str((temp_dir / "pkg_b" / "module_b.py").resolve())
assert resolved_file == file_b
assert start_line == 1
assert end_line == 15
finally:
gsi.close()
def test_expand_empty_results_returns_empty(temp_dir: Path) -> None:
"""expand() with empty input should return empty list."""
db_path = temp_dir / "empty.db"
gsi = GlobalSymbolIndex(db_path, project_id=1)
gsi.initialize()
try:
expander = GlobalGraphExpander(gsi)
assert expander.expand([]) == []
finally:
gsi.close()
def test_expand_results_without_symbol_names_returns_empty(temp_dir: Path) -> None:
"""expand() should skip results without symbol_name."""
db_path = temp_dir / "nosym.db"
gsi = GlobalSymbolIndex(db_path, project_id=1)
gsi.initialize()
try:
expander = GlobalGraphExpander(gsi)
base_results = [
SearchResult(
path="/some/file.py",
score=1.0,
excerpt="some text",
content=None,
start_line=1,
end_line=5,
symbol_name=None,
symbol_kind=None,
),
]
assert expander.expand(base_results) == []
finally:
gsi.close()