mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-14 02:42:04 +08:00
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:
@@ -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
|
||||
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
```
|
||||
|
||||
@@ -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个视角进行结构化分析:
|
||||
|
||||
@@ -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
|
||||
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]`
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
|
||||
\`\`\`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
250
codex-lens/src/codexlens/search/global_graph_expander.py
Normal file
250
codex-lens/src/codexlens/search/global_graph_expander.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
|
||||
323
codex-lens/tests/test_global_graph_expander.py
Normal file
323
codex-lens/tests/test_global_graph_expander.py
Normal 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()
|
||||
Reference in New Issue
Block a user