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路径无效, 无法加载实现计划" })
|
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
|
## 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 未找到, 使用默认结构" })
|
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
|
## 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未找到, 无法进行需求验证" })
|
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
|
## 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: "代码库探索失败: 项目根目录无法识别" })
|
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
|
## Execution Process
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -31,6 +31,26 @@ mcp__ccw-tools__team_msg({ operation: "status", team: teamName })
|
|||||||
**日志位置**: `.workflow/.team-msg/{team-name}/messages.jsonl`
|
**日志位置**: `.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`
|
**消息类型**: `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
|
## 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" })
|
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个视角进行结构化分析:
|
每个讨论轮次从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"] } })
|
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
|
## 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" })
|
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
|
## 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命令未找到, 请确认测试框架已安装" })
|
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
|
## Execution Process
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -68,7 +68,18 @@ ${config.message_types.filter(mt => mt.type !== 'error').map(mt =>
|
|||||||
`// ${mt.trigger}
|
`// ${mt.trigger}
|
||||||
mcp__ccw-tools__team_msg({ operation: "log", team: teamName, from: "${config.role_name}", to: "coordinator", type: "${mt.type}", summary: "${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')}
|
).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
|
### Step 4: Generate Phase Implementations
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ const requiredSections = [
|
|||||||
{ name: "TaskGet Usage", pattern: /TaskGet/ },
|
{ name: "TaskGet Usage", pattern: /TaskGet/ },
|
||||||
{ name: "TaskUpdate Usage", pattern: /TaskUpdate/ },
|
{ name: "TaskUpdate Usage", pattern: /TaskUpdate/ },
|
||||||
{ name: "SendMessage to Coordinator", pattern: /SendMessage.*coordinator/i },
|
{ 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: "Error Handling Table", pattern: /## Error Handling/ },
|
||||||
{ name: "Implementation Section", pattern: /## Implementation/ }
|
{ name: "Implementation Section", pattern: /## Implementation/ }
|
||||||
]
|
]
|
||||||
@@ -110,6 +112,14 @@ const patternChecks = [
|
|||||||
return command.includes('idle') || command.includes('return')
|
return command.includes('idle') || command.includes('return')
|
||||||
},
|
},
|
||||||
severity: "medium"
|
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:**
|
**Infrastructure Pattern Checklist:**
|
||||||
- [ ] Pattern 1: Message bus - team_msg before every SendMessage
|
- [ ] 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 2: YAML front matter - all fields present, group: team
|
||||||
- [ ] Pattern 3: Task lifecycle - TaskList/Get/Update flow
|
- [ ] Pattern 3: Task lifecycle - TaskList/Get/Update flow
|
||||||
- [ ] Pattern 4: Five-phase structure - all 5 phases present
|
- [ ] 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
|
- Missing error handling table
|
||||||
- Incomplete Phase implementation (skeleton only)
|
- Incomplete Phase implementation (skeleton only)
|
||||||
- Missing team_msg before some SendMessage calls
|
- Missing team_msg before some SendMessage calls
|
||||||
|
- Missing CLI fallback section (`### CLI 回退` with `ccw team` examples)
|
||||||
- No complexity-adaptive routing when role is complex
|
- No complexity-adaptive routing when role is complex
|
||||||
|
|
||||||
### Info (Nice to Have)
|
### 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}_complete` - Work phase finished
|
||||||
- `{action}_progress` - Intermediate progress update
|
- `{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
|
### Message Bus Section Template
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
## Message Bus
|
## 消息总线
|
||||||
|
|
||||||
Every SendMessage **before**, must call `mcp__ccw-tools__team_msg` to log:
|
每次 SendMessage **前**,必须调用 `mcp__ccw-tools__team_msg` 记录消息:
|
||||||
|
|
||||||
\`\`\`javascript
|
\`\`\`javascript
|
||||||
mcp__ccw-tools__team_msg({ operation: "log", team: teamName, from: "<role>", to: "coordinator", type: "<type>", summary: "<summary>" })
|
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 | 方向 | 触发时机 | 说明 |
|
||||||
|------|-----------|---------|-------------|
|
|------|------|----------|------|
|
||||||
| `<type>` | <role> -> coordinator | <when> | <what> |
|
| `<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}}
|
{{/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
|
## Execution Process
|
||||||
|
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|||||||
@@ -146,6 +146,11 @@ class Config:
|
|||||||
staged_coarse_k: int = 200 # Number of coarse candidates from Stage 1 binary search
|
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_lsp_depth: int = 2 # LSP relationship expansion depth in Stage 2
|
||||||
staged_stage2_mode: str = "precomputed" # "precomputed" (graph_neighbors) | "realtime" (LSP)
|
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_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_depth: int = 1 # BFS depth for realtime LSP expansion
|
||||||
staged_realtime_lsp_max_nodes: int = 50 # Node cap for realtime graph expansion
|
staged_realtime_lsp_max_nodes: int = 50 # Node cap for realtime graph expansion
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from .chain_search import (
|
|||||||
ChainSearchResult,
|
ChainSearchResult,
|
||||||
quick_search,
|
quick_search,
|
||||||
)
|
)
|
||||||
|
from .global_graph_expander import GlobalGraphExpander
|
||||||
|
|
||||||
# Clustering availability flag (lazy import pattern)
|
# Clustering availability flag (lazy import pattern)
|
||||||
CLUSTERING_AVAILABLE = False
|
CLUSTERING_AVAILABLE = False
|
||||||
@@ -46,6 +47,7 @@ __all__ = [
|
|||||||
"SearchStats",
|
"SearchStats",
|
||||||
"ChainSearchResult",
|
"ChainSearchResult",
|
||||||
"quick_search",
|
"quick_search",
|
||||||
|
"GlobalGraphExpander",
|
||||||
# Clustering
|
# Clustering
|
||||||
"CLUSTERING_AVAILABLE",
|
"CLUSTERING_AVAILABLE",
|
||||||
"check_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,
|
"supported_languages": self.config.supported_languages,
|
||||||
"parsing_rules": self.config.parsing_rules,
|
"parsing_rules": self.config.parsing_rules,
|
||||||
"global_symbol_index_enabled": self.config.global_symbol_index_enabled,
|
"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 = [
|
worker_args = [
|
||||||
@@ -627,6 +629,27 @@ class IndexTreeBuilder:
|
|||||||
relationships=indexed_file.relationships,
|
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
|
files_count += 1
|
||||||
symbols_count += len(indexed_file.symbols)
|
symbols_count += len(indexed_file.symbols)
|
||||||
|
|
||||||
@@ -959,6 +982,8 @@ def _build_dir_worker(args: tuple) -> DirBuildResult:
|
|||||||
supported_languages=config_dict["supported_languages"],
|
supported_languages=config_dict["supported_languages"],
|
||||||
parsing_rules=config_dict["parsing_rules"],
|
parsing_rules=config_dict["parsing_rules"],
|
||||||
global_symbol_index_enabled=bool(config_dict.get("global_symbol_index_enabled", True)),
|
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)
|
parser_factory = ParserFactory(config)
|
||||||
@@ -1008,6 +1033,25 @@ def _build_dir_worker(args: tuple) -> DirBuildResult:
|
|||||||
relationships=indexed_file.relationships,
|
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
|
files_count += 1
|
||||||
symbols_count += len(indexed_file.symbols)
|
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