diff --git a/.claude/commands/team/execute.md b/.claude/commands/team/execute.md index 9cf8b874..01582a55 100644 --- a/.claude/commands/team/execute.md +++ b/.claude/commands/team/execute.md @@ -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 --from executor --to coordinator --type --summary "" [--ref ] [--data ''] [--json]` + ## Execution Process ``` diff --git a/.claude/commands/team/plan.md b/.claude/commands/team/plan.md index e8b231d3..10c437cc 100644 --- a/.claude/commands/team/plan.md +++ b/.claude/commands/team/plan.md @@ -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 --from planner --to coordinator --type --summary "" [--ref ] [--data ''] [--json]` + ## Execution Process ``` diff --git a/.claude/commands/team/review.md b/.claude/commands/team/review.md index da472f9c..cef38a0b 100644 --- a/.claude/commands/team/review.md +++ b/.claude/commands/team/review.md @@ -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 --from tester --to coordinator --type --summary "" [--data ''] [--json]` + ## Execution Process ``` diff --git a/.claude/commands/team/spec-analyst.md b/.claude/commands/team/spec-analyst.md index d6d530f4..b2c46367 100644 --- a/.claude/commands/team/spec-analyst.md +++ b/.claude/commands/team/spec-analyst.md @@ -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 --from spec-analyst --to coordinator --type --summary "" [--ref ] [--data ''] [--json]` + ## Execution Process ``` diff --git a/.claude/commands/team/spec-coordinate.md b/.claude/commands/team/spec-coordinate.md index 1384c8ec..ed790835 100644 --- a/.claude/commands/team/spec-coordinate.md +++ b/.claude/commands/team/spec-coordinate.md @@ -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 --team [--from/--to/--type/--summary/--ref/--data/--id/--last] [--json]` + ## Pipeline ``` diff --git a/.claude/commands/team/spec-discuss.md b/.claude/commands/team/spec-discuss.md index 1058c4fd..97689128 100644 --- a/.claude/commands/team/spec-discuss.md +++ b/.claude/commands/team/spec-discuss.md @@ -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 --from spec-discuss --to coordinator --type --summary "" [--ref ] [--data ''] [--json]` + ## 讨论维度模型 每个讨论轮次从4个视角进行结构化分析: diff --git a/.claude/commands/team/spec-reviewer.md b/.claude/commands/team/spec-reviewer.md index 6c6a7a0f..7d551343 100644 --- a/.claude/commands/team/spec-reviewer.md +++ b/.claude/commands/team/spec-reviewer.md @@ -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 --from spec-reviewer --to coordinator --type --summary "" [--data ''] [--json]` + ## Execution Process ``` diff --git a/.claude/commands/team/spec-writer.md b/.claude/commands/team/spec-writer.md index 3da77043..8cf0b91e 100644 --- a/.claude/commands/team/spec-writer.md +++ b/.claude/commands/team/spec-writer.md @@ -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 --from spec-writer --to coordinator --type --summary "" [--ref ] [--data ''] [--json]` + ## Execution Process ``` diff --git a/.claude/commands/team/test.md b/.claude/commands/team/test.md index 5089e80f..34fba44b 100644 --- a/.claude/commands/team/test.md +++ b/.claude/commands/team/test.md @@ -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 --from tester --to coordinator --type --summary "" [--data ''] [--json]` + ## Execution Process ``` diff --git a/.claude/skills/team-command-designer/phases/03-command-generation.md b/.claude/skills/team-command-designer/phases/03-command-generation.md index 25776647..3cefd770 100644 --- a/.claude/skills/team-command-designer/phases/03-command-generation.md +++ b/.claude/skills/team-command-designer/phases/03-command-generation.md @@ -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 --from ${config.role_name} --to coordinator --type --summary "" [--ref ] [--data ''] [--json]\`` ``` ### Step 4: Generate Phase Implementations diff --git a/.claude/skills/team-command-designer/phases/05-validation.md b/.claude/skills/team-command-designer/phases/05-validation.md index 2d39a9ac..85e6d975 100644 --- a/.claude/skills/team-command-designer/phases/05-validation.md +++ b/.claude/skills/team-command-designer/phases/05-validation.md @@ -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" } ] diff --git a/.claude/skills/team-command-designer/specs/quality-standards.md b/.claude/skills/team-command-designer/specs/quality-standards.md index 347403fa..e75a188f 100644 --- a/.claude/skills/team-command-designer/specs/quality-standards.md +++ b/.claude/skills/team-command-designer/specs/quality-standards.md @@ -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) diff --git a/.claude/skills/team-command-designer/specs/team-design-patterns.md b/.claude/skills/team-command-designer/specs/team-design-patterns.md index e666528f..f5dde22e 100644 --- a/.claude/skills/team-command-designer/specs/team-design-patterns.md +++ b/.claude/skills/team-command-designer/specs/team-design-patterns.md @@ -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 "" --to "coordinator" --type "" --summary "" [--ref ] [--data ''] --json`) +``` + +**Parameter mapping**: `team_msg(params)` → `ccw 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: "", to: "coordinator", type: "", summary: "" }) \`\`\` -### Supported Message Types +### 支持的 Message Types -| Type | Direction | Trigger | Description | -|------|-----------|---------|-------------| -| `` | -> coordinator | | | +| Type | 方向 | 触发时机 | 说明 | +|------|------|----------|------| +| `` | → coordinator | | | + +### CLI 回退 + +当 `mcp__ccw-tools__team_msg` MCP 不可用时,使用 `ccw team` CLI 作为等效回退: + +\`\`\`javascript +// 回退: 将 MCP 调用替换为 Bash CLI(参数一一对应) +Bash(\`ccw team log --team "${teamName}" --from "" --to "coordinator" --type "" --summary "" --json\`) +\`\`\` + +**参数映射**: `team_msg(params)` → `ccw team log --team --from --to coordinator --type --summary "" [--ref ] [--data ''] [--json]` ``` --- diff --git a/.claude/skills/team-command-designer/templates/command-template.md b/.claude/skills/team-command-designer/templates/command-template.md index 4c45ed7a..a43a7525 100644 --- a/.claude/skills/team-command-designer/templates/command-template.md +++ b/.claude/skills/team-command-designer/templates/command-template.md @@ -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 --from {{role_name}} --to coordinator --type --summary "" [--ref ] [--data ''] [--json]` + ## Execution Process \`\`\` diff --git a/codex-lens/src/codexlens/config.py b/codex-lens/src/codexlens/config.py index a03ceda6..233745c3 100644 --- a/codex-lens/src/codexlens/config.py +++ b/codex-lens/src/codexlens/config.py @@ -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 diff --git a/codex-lens/src/codexlens/search/__init__.py b/codex-lens/src/codexlens/search/__init__.py index 46c660f4..e8749930 100644 --- a/codex-lens/src/codexlens/search/__init__.py +++ b/codex-lens/src/codexlens/search/__init__.py @@ -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", diff --git a/codex-lens/src/codexlens/search/global_graph_expander.py b/codex-lens/src/codexlens/search/global_graph_expander.py new file mode 100644 index 00000000..b6aa682e --- /dev/null +++ b/codex-lens/src/codexlens/search/global_graph_expander.py @@ -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 diff --git a/codex-lens/src/codexlens/storage/index_tree.py b/codex-lens/src/codexlens/storage/index_tree.py index 40ad85e7..8f61eb74 100644 --- a/codex-lens/src/codexlens/storage/index_tree.py +++ b/codex-lens/src/codexlens/storage/index_tree.py @@ -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) diff --git a/codex-lens/tests/test_global_graph_expander.py b/codex-lens/tests/test_global_graph_expander.py new file mode 100644 index 00000000..37fa9371 --- /dev/null +++ b/codex-lens/tests/test_global_graph_expander.py @@ -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()