Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 60 additions & 19 deletions internal/audit/analyzer.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
package audit

import (
Expand All @@ -11,22 +11,29 @@
"strings"
"time"

"github.com/SimplyLiz/CodeMCP/internal/complexity"
"github.com/SimplyLiz/CodeMCP/internal/coupling"
)

// Analyzer performs risk analysis on codebases
type Analyzer struct {
repoRoot string
logger *slog.Logger
couplingAnalyzer *coupling.Analyzer
repoRoot string
logger *slog.Logger
couplingAnalyzer *coupling.Analyzer
complexityAnalyzer *complexity.Analyzer
}

// NewAnalyzer creates a new risk analyzer
func NewAnalyzer(repoRoot string, logger *slog.Logger) *Analyzer {
var ca *complexity.Analyzer
if complexity.IsAvailable() {
ca = complexity.NewAnalyzer()
}
return &Analyzer{
repoRoot: repoRoot,
logger: logger,
couplingAnalyzer: coupling.NewAnalyzer(repoRoot, logger),
repoRoot: repoRoot,
logger: logger,
couplingAnalyzer: coupling.NewAnalyzer(repoRoot, logger),
complexityAnalyzer: ca,
}
}

Expand Down Expand Up @@ -116,12 +123,12 @@
factors := make([]RiskFactor, 0, 8)
fullPath := filepath.Join(repoRoot, file)

// 1. Complexity (0-20 contribution)
complexity := a.getComplexity(fullPath)
complexityContrib := min(float64(complexity)/100, 1.0) * 20
// 1. Complexity (0-20 contribution) with per-function breakdown
totalComplexity, functionRisks := a.getComplexityDetailed(ctx, fullPath)
complexityContrib := min(float64(totalComplexity)/100, 1.0) * 20
factors = append(factors, RiskFactor{
Factor: FactorComplexity,
Value: fmt.Sprintf("%d", complexity),
Value: fmt.Sprintf("%d", totalComplexity),
Weight: RiskWeights[FactorComplexity],
Contribution: complexityContrib,
})
Expand Down Expand Up @@ -230,11 +237,12 @@
recommendation := a.generateRecommendation(factors)

return &RiskItem{
File: file,
RiskScore: totalScore,
RiskLevel: GetRiskLevel(totalScore),
Factors: factors,
Recommendation: recommendation,
File: file,
RiskScore: totalScore,
RiskLevel: GetRiskLevel(totalScore),
Factors: factors,
Recommendation: recommendation,
FunctionComplexity: functionRisks,
}, nil
}

Expand Down Expand Up @@ -269,18 +277,51 @@
return files, err
}

// getComplexity estimates complexity based on file size and structure
func (a *Analyzer) getComplexity(filePath string) int {
// getComplexityDetailed returns total complexity and per-function breakdown.
// When the tree-sitter complexity analyzer is available, delegates to it for
// accurate per-function cyclomatic+cognitive scores. Falls back to string-counting heuristic.
func (a *Analyzer) getComplexityDetailed(ctx context.Context, filePath string) (int, []FunctionRisk) {
// Try tree-sitter analyzer first
if a.complexityAnalyzer != nil {
fc, err := a.complexityAnalyzer.AnalyzeFile(ctx, filePath)
if err == nil && fc != nil && fc.Error == "" && len(fc.Functions) > 0 {
// Convert to FunctionRisk and sort by cyclomatic descending
risks := make([]FunctionRisk, 0, len(fc.Functions))
for _, f := range fc.Functions {
risks = append(risks, FunctionRisk{
Name: f.Name,
StartLine: f.StartLine,
EndLine: f.EndLine,
Cyclomatic: f.Cyclomatic,
Cognitive: f.Cognitive,
Lines: f.Lines,
})
}
sort.Slice(risks, func(i, j int) bool {
return risks[i].Cyclomatic > risks[j].Cyclomatic
})
// Cap at top 10 per file
if len(risks) > 10 {
risks = risks[:10]
}
return fc.TotalCyclomatic, risks
}
}

// Fallback: simple heuristic, no per-function breakdown
return a.getComplexityHeuristic(filePath), nil
}

// getComplexityHeuristic estimates complexity based on string counting.
func (a *Analyzer) getComplexityHeuristic(filePath string) int {
content, err := os.ReadFile(filePath)
if err != nil {
return 0
}

// Simple heuristic: count decision points
text := string(content)
complexity := 1 // Base complexity

// Count various complexity indicators
complexity += strings.Count(text, "if ") + strings.Count(text, "if(")
complexity += strings.Count(text, "else ")
complexity += strings.Count(text, "for ") + strings.Count(text, "for(")
Expand Down
8 changes: 4 additions & 4 deletions internal/audit/audit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -258,21 +258,21 @@ func main() {
t.Fatal(err)
}

complexity := analyzer.getComplexity(testFile)
complexity := analyzer.getComplexityHeuristic(testFile)
// Should detect: 2 if, 1 for, 1 switch, 2 case, 1 &&
// Base complexity 1 + 2 + 1 + 1 + 2 + 1 = 8
if complexity < 5 {
t.Errorf("getComplexity() = %d, want >= 5", complexity)
t.Errorf("getComplexityHeuristic() = %d, want >= 5", complexity)
}
}

func TestGetComplexityNonexistent(t *testing.T) {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
analyzer := NewAnalyzer("/tmp", logger)

complexity := analyzer.getComplexity("/nonexistent/file.go")
complexity := analyzer.getComplexityHeuristic("/nonexistent/file.go")
if complexity != 0 {
t.Errorf("getComplexity() for nonexistent file = %d, want 0", complexity)
t.Errorf("getComplexityHeuristic() for nonexistent file = %d, want 0", complexity)
}
}

Expand Down
23 changes: 17 additions & 6 deletions internal/audit/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,25 @@ type RiskAnalysis struct {
QuickWins []QuickWin `json:"quickWins"`
}

// FunctionRisk contains per-function complexity metrics within a risky file.
type FunctionRisk struct {
Name string `json:"name"`
StartLine int `json:"startLine"`
EndLine int `json:"endLine"`
Cyclomatic int `json:"cyclomatic"`
Cognitive int `json:"cognitive"`
Lines int `json:"lines"`
}

// RiskItem represents a single file/module with risk assessment
type RiskItem struct {
File string `json:"file"`
Module string `json:"module,omitempty"`
RiskScore float64 `json:"riskScore"` // 0-100
RiskLevel string `json:"riskLevel"` // "critical" | "high" | "medium" | "low"
Factors []RiskFactor `json:"factors"`
Recommendation string `json:"recommendation,omitempty"`
File string `json:"file"`
Module string `json:"module,omitempty"`
RiskScore float64 `json:"riskScore"` // 0-100
RiskLevel string `json:"riskLevel"` // "critical" | "high" | "medium" | "low"
Factors []RiskFactor `json:"factors"`
Recommendation string `json:"recommendation,omitempty"`
FunctionComplexity []FunctionRisk `json:"functionComplexity,omitempty"`
}

// RiskFactor represents a contributing factor to the risk score
Expand Down
24 changes: 19 additions & 5 deletions internal/mcp/presets.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ var Presets = map[string][]string{
"getStatus",
"switchProject", // v8.1: Dynamic project switching

// v8.1 Refactoring compound tool
"planRefactor",

// Meta (always included)
"expandToolset",
},
Expand All @@ -72,7 +75,9 @@ var Presets = map[string][]string{
"searchSymbols", "getSymbol", "explainSymbol", "explainFile",
"findReferences", "getCallGraph", "traceUsage",
"getArchitecture", "getModuleOverview", "listKeyConcepts",
"analyzeImpact", "getHotspots", "getStatus", "switchProject", "expandToolset",
"analyzeImpact", "getHotspots", "getStatus", "switchProject",
"planRefactor", // v8.1
"expandToolset",
// Review-specific
"summarizeDiff",
"summarizePr",
Expand All @@ -99,7 +104,9 @@ var Presets = map[string][]string{
"compareAPI", // v7.6: Breaking change detection
"auditRisk",
"explainOrigin",
"scanSecrets", // v8.0: Secret detection for security audits
"scanSecrets", // v8.0: Secret detection for security audits
"analyzeTestGaps", // v8.1: Test gap analysis
"planRefactor", // v8.1: Unified refactor planning
},

// Federation: core + federation tools
Expand All @@ -109,7 +116,9 @@ var Presets = map[string][]string{
"searchSymbols", "getSymbol", "explainSymbol", "explainFile",
"findReferences", "getCallGraph", "traceUsage",
"getArchitecture", "getModuleOverview", "listKeyConcepts",
"analyzeImpact", "getHotspots", "getStatus", "switchProject", "expandToolset",
"analyzeImpact", "getHotspots", "getStatus", "switchProject",
"planRefactor", // v8.1
"expandToolset",
// Federation-specific
"listFederations",
"federationStatus",
Expand All @@ -134,7 +143,9 @@ var Presets = map[string][]string{
"searchSymbols", "getSymbol", "explainSymbol", "explainFile",
"findReferences", "getCallGraph", "traceUsage",
"getArchitecture", "getModuleOverview", "listKeyConcepts",
"analyzeImpact", "getHotspots", "getStatus", "switchProject", "expandToolset",
"analyzeImpact", "getHotspots", "getStatus", "switchProject",
"planRefactor", // v8.1
"expandToolset",
// Docs-specific
"indexDocs",
"getDocsForSymbol",
Expand All @@ -151,7 +162,9 @@ var Presets = map[string][]string{
"searchSymbols", "getSymbol", "explainSymbol", "explainFile",
"findReferences", "getCallGraph", "traceUsage",
"getArchitecture", "getModuleOverview", "listKeyConcepts",
"analyzeImpact", "getHotspots", "getStatus", "switchProject", "expandToolset",
"analyzeImpact", "getHotspots", "getStatus", "switchProject",
"planRefactor", // v8.1
"expandToolset",
// Ops-specific
"doctor",
"reindex",
Expand Down Expand Up @@ -223,6 +236,7 @@ var coreToolOrder = []string{
"getHotspots",
"getStatus",
"switchProject",
"planRefactor", // v8.1
"expandToolset",
}

Expand Down
15 changes: 8 additions & 7 deletions internal/mcp/presets_test.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
package mcp

import (
Expand All @@ -12,10 +12,10 @@
server := NewMCPServer("test", nil, logger)

// Test core preset (default)
// v8.1: Core now includes 5 compound tools + switchProject
// v8.1: Core now includes 5 compound tools + switchProject + planRefactor
coreTools := server.GetFilteredTools()
if len(coreTools) != 20 {
t.Errorf("expected 20 core tools (v8.1 includes switchProject), got %d", len(coreTools))
if len(coreTools) != 21 {
t.Errorf("expected 21 core tools (v8.1 includes planRefactor), got %d", len(coreTools))
}

// Verify compound tools come first (preferred for AI workflows)
Expand All @@ -24,7 +24,8 @@
"searchSymbols", "getSymbol", "explainSymbol", "explainFile",
"findReferences", "getCallGraph", "traceUsage",
"getArchitecture", "getModuleOverview", "listKeyConcepts",
"analyzeImpact", "getHotspots", "getStatus", "switchProject", "expandToolset",
"analyzeImpact", "getHotspots", "getStatus", "switchProject",
"planRefactor", "expandToolset",
}
for i, expected := range expectedFirst {
if i >= len(coreTools) {
Expand All @@ -41,9 +42,9 @@
t.Fatalf("failed to set full preset: %v", err)
}
fullTools := server.GetFilteredTools()
// v8.1: Full now includes switchProject (88 = 87 + 1)
if len(fullTools) != 88 {
t.Errorf("expected 88 full tools (v8.1 includes switchProject), got %d", len(fullTools))
// v8.1: Full now includes switchProject + analyzeTestGaps + planRefactor (90 = 88 + 2)
if len(fullTools) != 90 {
t.Errorf("expected 90 full tools (v8.1 includes analyzeTestGaps + planRefactor), got %d", len(fullTools))
}

// Full preset should still have core tools first
Expand Down
66 changes: 57 additions & 9 deletions internal/mcp/tool_impls_compound.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
package mcp

import (
Expand Down Expand Up @@ -57,9 +57,11 @@
return nil, s.enrichNotFoundError(err)
}

return NewToolResponse().
Data(result).
Build(), nil
resp := NewToolResponse().Data(result)
for _, dw := range engine.GetDegradationWarnings() {
resp.Warning(dw.Message)
}
return resp.Build(), nil
}

// toolUnderstand provides comprehensive symbol deep-dive
Expand Down Expand Up @@ -100,9 +102,11 @@
return nil, s.enrichNotFoundError(err)
}

return NewToolResponse().
Data(result).
Build(), nil
resp := NewToolResponse().Data(result)
for _, dw := range engine.GetDegradationWarnings() {
resp.Warning(dw.Message)
}
return resp.Build(), nil
}

// toolPrepareChange provides pre-change analysis
Expand Down Expand Up @@ -140,9 +144,11 @@
return nil, s.enrichNotFoundError(err)
}

return NewToolResponse().
Data(result).
Build(), nil
resp := NewToolResponse().Data(result)
for _, dw := range engine.GetDegradationWarnings() {
resp.Warning(dw.Message)
}
return resp.Build(), nil
}

// toolSwitchProject switches CKB to a different project directory
Expand Down Expand Up @@ -257,3 +263,45 @@
Data(result).
Build(), nil
}

// toolPlanRefactor provides unified refactoring planning
func (s *MCPServer) toolPlanRefactor(params map[string]interface{}) (*envelope.Response, error) {
target, ok := params["target"].(string)
if !ok || target == "" {
return nil, errors.NewInvalidParameterError("target", "required")
}

changeType := query.ChangeModify
if v, ok := params["changeType"].(string); ok {
switch v {
case "modify":
changeType = query.ChangeModify
case "rename":
changeType = query.ChangeRename
case "delete":
changeType = query.ChangeDelete
case "extract":
changeType = query.ChangeExtract
}
}

engine, err := s.GetEngine()
if err != nil {
return nil, err
}

ctx := context.Background()
result, err := engine.PlanRefactor(ctx, query.PlanRefactorOptions{
Target: target,
ChangeType: changeType,
})
if err != nil {
return nil, s.enrichNotFoundError(err)
}

resp := NewToolResponse().Data(result)
for _, dw := range engine.GetDegradationWarnings() {
resp.Warning(dw.Message)
}
return resp.Build(), nil
}
Loading
Loading