Skip to content

Commit 4b515e5

Browse files
SimplyLizclaude
andauthored
feat: add findUnwiredModules — entrypoint reachability check (#200)
Add a new check that detects exported symbols never transitively reachable from application entrypoints (main, server, CLI). This catches the "built but never plugged in" pattern where modules exist and are tested but aren't wired into the execution pipeline. Algorithm: BFS forward from entrypoint files using SCIP call graph, building a reachable set (10K node budget), then flags exported functions/methods not in the set. Exposed via all interfaces: - CLI: ckb unwired [--scope, --format, --min-confidence] - HTTP API: GET /unwired?scope=...&minConfidence=...&limit=... - MCP: findUnwiredModules tool (review + refactor presets) - PR Review: "unwired" check (21st quality check in ckb review) New package: internal/unwired/ (detector + types) New engine method: query.FindUnwiredModules() Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 48f7584 commit 4b515e5

File tree

14 files changed

+1268
-24
lines changed

14 files changed

+1268
-24
lines changed

CLAUDE.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,12 @@ golangci-lint run
5151
# Start A2A protocol server (for agent-to-agent communication)
5252
./ckb a2a --port 8081
5353

54-
# Run PR review (20 quality checks)
54+
# Find exported symbols not reachable from entrypoints
55+
./ckb unwired
56+
./ckb unwired --scope internal/cost,internal/multisampling
57+
./ckb unwired --format json
58+
59+
# Run PR review (21 quality checks)
5560
./ckb review
5661
./ckb review --base=develop --format=markdown
5762
./ckb review --checks=breaking,secrets,health --ci
@@ -124,15 +129,15 @@ claude mcp add ckb -- npx @tastehub/ckb mcp
124129

125130
**Intelligence (v6.5):** `explainOrigin`, `analyzeCoupling`, `exportForLLM`, `auditRisk`
126131

127-
**Code Analysis (v7.6):** `findDeadCode` (static dead code detection), `getAffectedTests`, `compareAPI`
132+
**Code Analysis (v7.6):** `findDeadCode` (static dead code detection), `findUnwiredModules` (entrypoint reachability — detects "built but never plugged in" modules), `getAffectedTests`, `compareAPI`
128133

129134
**Compound Operations (v8.0):** `explore`, `understand`, `prepareChange`, `batchGet`, `batchSearch`
130135

131136
**Streaming (v8.0):** `findReferences` and `searchSymbols` support SSE streaming with `stream: true`
132137

133138
**Index Management (v8.0):** `reindex` (trigger index refresh), enhanced `getStatus` with health tiers
134139

135-
**PR Review (v8.2):** `reviewPR` — unified review with 20 quality checks (breaking, secrets, tests, complexity, health, coupling, hotspots, risk, critical-path, traceability, independence, generated, classify, split, dead-code, test-gaps, blast-radius, comment-drift, format-consistency, bug-patterns); optional `--llm` flag for Claude-powered narrative
140+
**PR Review (v8.2):** `reviewPR` — unified review with 21 quality checks (breaking, secrets, tests, complexity, health, coupling, hotspots, risk, critical-path, traceability, independence, generated, classify, split, dead-code, unwired, test-gaps, blast-radius, comment-drift, format-consistency, bug-patterns); optional `--llm` flag for Claude-powered narrative
136141

137142
## A2A Integration
138143

cmd/ckb/unwired.go

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"time"
8+
9+
"github.com/spf13/cobra"
10+
11+
"github.com/SimplyLiz/CodeMCP/internal/query"
12+
)
13+
14+
var (
15+
unwiredFormat string
16+
unwiredScope []string
17+
unwiredLimit int
18+
unwiredMinConfidence float64
19+
unwiredExclude []string
20+
unwiredIncludeTypes bool
21+
unwiredMaxNodes int
22+
)
23+
24+
var unwiredCmd = &cobra.Command{
25+
Use: "unwired",
26+
Short: "Find exported symbols not reachable from entrypoints",
27+
Long: `Find exported functions and methods that are never transitively called
28+
from any application entrypoint (main, server, CLI commands).
29+
30+
This detects the "built but never plugged in" pattern where modules exist
31+
and are tested but aren't wired into the execution pipeline.
32+
33+
Complements dead-code (which checks reference counts) by checking
34+
reachability from the actual runtime entry points.
35+
36+
Examples:
37+
ckb unwired
38+
ckb unwired --scope src/cost,src/multisampling
39+
ckb unwired --min-confidence 0.9
40+
ckb unwired --include-types
41+
ckb unwired --format json`,
42+
Run: runUnwired,
43+
}
44+
45+
func init() {
46+
unwiredCmd.Flags().StringVar(&unwiredFormat, "format", "human", "Output format (human, json)")
47+
unwiredCmd.Flags().StringSliceVar(&unwiredScope, "scope", nil, "Limit to specific packages/paths")
48+
unwiredCmd.Flags().IntVar(&unwiredLimit, "limit", 100, "Maximum results to return")
49+
unwiredCmd.Flags().Float64Var(&unwiredMinConfidence, "min-confidence", 0.80, "Minimum confidence threshold (0-1)")
50+
unwiredCmd.Flags().StringSliceVar(&unwiredExclude, "exclude", nil, "Patterns to exclude")
51+
unwiredCmd.Flags().BoolVar(&unwiredIncludeTypes, "include-types", false, "Include type definitions (higher FP rate)")
52+
unwiredCmd.Flags().IntVar(&unwiredMaxNodes, "max-nodes", 10000, "Max symbols in reachable set")
53+
rootCmd.AddCommand(unwiredCmd)
54+
}
55+
56+
func runUnwired(cmd *cobra.Command, args []string) {
57+
start := time.Now()
58+
logger := newLogger(unwiredFormat)
59+
60+
repoRoot := mustGetRepoRoot()
61+
engine := mustGetEngine(repoRoot, logger)
62+
ctx := newContext()
63+
64+
opts := query.FindUnwiredModulesOptions{
65+
Scope: unwiredScope,
66+
ExcludePatterns: unwiredExclude,
67+
MinConfidence: unwiredMinConfidence,
68+
IncludeTypes: unwiredIncludeTypes,
69+
MaxNodes: unwiredMaxNodes,
70+
Limit: unwiredLimit,
71+
}
72+
73+
response, err := engine.FindUnwiredModules(ctx, opts)
74+
if err != nil {
75+
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
76+
os.Exit(1)
77+
}
78+
79+
if unwiredFormat == "json" {
80+
data, err := json.MarshalIndent(response, "", " ")
81+
if err != nil {
82+
fmt.Fprintf(os.Stderr, "Error formatting output: %v\n", err)
83+
os.Exit(1)
84+
}
85+
fmt.Println(string(data))
86+
} else {
87+
printUnwiredHuman(response)
88+
}
89+
90+
logger.Debug("Unwired analysis completed",
91+
"unwiredCount", response.Summary.UnwiredCount,
92+
"reachableCount", response.ReachableCount,
93+
"duration", time.Since(start).Milliseconds(),
94+
)
95+
}
96+
97+
func printUnwiredHuman(resp *query.FindUnwiredModulesResponse) {
98+
fmt.Println("Unwired Module Analysis")
99+
fmt.Println("============================================================")
100+
fmt.Println()
101+
102+
if len(resp.Entrypoints) > 0 {
103+
fmt.Printf("Entrypoints: %d\n", len(resp.Entrypoints))
104+
for _, ep := range resp.Entrypoints {
105+
fmt.Printf(" - %s\n", ep)
106+
}
107+
fmt.Println()
108+
}
109+
110+
fmt.Printf("Reachable symbols: %d\n", resp.ReachableCount)
111+
fmt.Printf("Total exported: %d\n", resp.Summary.TotalExported)
112+
fmt.Printf("Unwired: %d\n", resp.Summary.UnwiredCount)
113+
if resp.Partial {
114+
fmt.Println(" (partial — reachable set budget exhausted)")
115+
}
116+
fmt.Println()
117+
118+
if len(resp.UnwiredModules) == 0 {
119+
fmt.Println("No unwired modules found.")
120+
return
121+
}
122+
123+
for _, mod := range resp.UnwiredModules {
124+
fmt.Printf("── %s (%d/%d exported symbols unwired)\n",
125+
mod.Path, mod.Summary.UnwiredCount, mod.Summary.TotalExported)
126+
for _, item := range mod.Items {
127+
fmt.Printf(" %s %s (%.0f%% confidence)\n",
128+
item.Kind, item.SymbolName, item.Confidence*100)
129+
fmt.Printf(" %s\n", item.Reason)
130+
if item.ReferenceCount > 0 {
131+
fmt.Printf(" refs: %d (test: %d)\n", item.ReferenceCount, item.TestReferences)
132+
}
133+
}
134+
fmt.Println()
135+
}
136+
}

internal/api/handlers_unwired.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package api
2+
3+
import (
4+
"net/http"
5+
"strconv"
6+
7+
"github.com/SimplyLiz/CodeMCP/internal/query"
8+
)
9+
10+
// handleUnwired handles GET /unwired — find exported symbols not reachable from entrypoints.
11+
func (s *Server) handleUnwired(w http.ResponseWriter, r *http.Request) {
12+
if r.Method != http.MethodGet {
13+
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
14+
return
15+
}
16+
17+
ctx := r.Context()
18+
q := r.URL.Query()
19+
20+
opts := query.FindUnwiredModulesOptions{
21+
MinConfidence: 0.80,
22+
Limit: 100,
23+
MaxNodes: 10000,
24+
}
25+
26+
if scope := q.Get("scope"); scope != "" {
27+
opts.Scope = []string{scope}
28+
}
29+
if minConf := q.Get("minConfidence"); minConf != "" {
30+
if v, err := strconv.ParseFloat(minConf, 64); err == nil {
31+
opts.MinConfidence = v
32+
}
33+
}
34+
if limit := q.Get("limit"); limit != "" {
35+
if v, err := strconv.Atoi(limit); err == nil {
36+
opts.Limit = v
37+
}
38+
}
39+
if q.Get("includeTypes") == "true" {
40+
opts.IncludeTypes = true
41+
}
42+
43+
result, err := s.engine.FindUnwiredModules(ctx, opts)
44+
if err != nil {
45+
InternalError(w, "Failed to find unwired modules", err)
46+
return
47+
}
48+
49+
WriteJSON(w, result, http.StatusOK)
50+
}

internal/api/routes.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ func (s *Server) registerRoutes() {
5555
// v8.2 Unified PR Review
5656
s.router.HandleFunc("/review/pr", s.handleReviewPR) // GET/POST
5757

58+
// Unwired module detection
59+
s.router.HandleFunc("/unwired", s.handleUnwired) // GET /unwired?scope=...&minConfidence=...&limit=...
60+
5861
// v6.2 Federation endpoints
5962
s.router.HandleFunc("/federations", s.handleListFederations) // GET
6063
s.router.HandleFunc("/federations/", s.handleFederationRoutes) // /federations/:name/*

internal/mcp/presets.go

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -88,17 +88,18 @@ var Presets = map[string][]string{
8888
"getOwnership",
8989
"getOwnershipDrift",
9090
"recentlyRelevant",
91-
"scanSecrets", // Secret detection for PR reviews
92-
"reviewPR", // Unified PR review with quality gates
93-
"getAffectedTests", // Tests covering changed code
94-
"analyzeTestGaps", // Untested functions in changed files
95-
"compareAPI", // Breaking API changes
96-
"findDeadCode", // Dead code in changes
97-
"auditRisk", // Multi-factor risk scoring
98-
"analyzeChange", // Change analysis
99-
"getFileComplexity", // File complexity for review
100-
"listEntrypoints", // Key entry points in changed code
101-
"auditCompliance", // Regulatory compliance audit
91+
"scanSecrets", // Secret detection for PR reviews
92+
"reviewPR", // Unified PR review with quality gates
93+
"getAffectedTests", // Tests covering changed code
94+
"analyzeTestGaps", // Untested functions in changed files
95+
"compareAPI", // Breaking API changes
96+
"findDeadCode", // Dead code in changes
97+
"findUnwiredModules", // Exported symbols not reachable from entrypoints
98+
"auditRisk", // Multi-factor risk scoring
99+
"analyzeChange", // Change analysis
100+
"getFileComplexity", // File complexity for review
101+
"listEntrypoints", // Key entry points in changed code
102+
"auditCompliance", // Regulatory compliance audit
102103
},
103104

104105
// Refactor: core + refactoring analysis tools
@@ -114,9 +115,10 @@ var Presets = map[string][]string{
114115
"justifySymbol",
115116
"analyzeCoupling",
116117
"findDeadCodeCandidates",
117-
"findDeadCode", // v7.6: Static dead code detection (no telemetry needed)
118-
"getAffectedTests", // v7.6: Find tests affected by changes
119-
"compareAPI", // v7.6: Breaking change detection
118+
"findDeadCode", // v7.6: Static dead code detection (no telemetry needed)
119+
"findUnwiredModules", // Exported symbols not reachable from entrypoints
120+
"getAffectedTests", // v7.6: Find tests affected by changes
121+
"compareAPI", // v7.6: Breaking change detection
120122
"auditRisk",
121123
"explainOrigin",
122124
"scanSecrets", // v8.0: Secret detection for security audits

internal/mcp/presets_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,9 @@ func TestPresetFiltering(t *testing.T) {
4242
t.Fatalf("failed to set full preset: %v", err)
4343
}
4444
fullTools := server.GetFilteredTools()
45-
// v8.3: Full now includes auditCompliance, listSymbols, getSymbolGraph (96)
46-
if len(fullTools) != 96 {
47-
t.Errorf("expected 96 full tools (v8.3), got %d", len(fullTools))
45+
// v8.4: Full now includes findUnwiredModules (97)
46+
if len(fullTools) != 97 {
47+
t.Errorf("expected 97 full tools, got %d", len(fullTools))
4848
}
4949

5050
// Full preset should still have core tools first

internal/mcp/token_budget_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ func TestToolsListTokenBudget(t *testing.T) {
3434
maxTools int
3535
}{
3636
{PresetCore, maxCorePresetBytes, 20, 24}, // v8.3: 24 tools (+explainPath, responsibilities, exportForLLM)
37-
{PresetReview, maxReviewPresetBytes, 30, 40}, // v8.3: 40 tools (+auditCompliance)
38-
{PresetFull, maxFullPresetBytes, 80, 96}, // v8.3: 96 tools (+listSymbols, getSymbolGraph)
37+
{PresetReview, maxReviewPresetBytes, 30, 41}, // v8.4: 41 tools (+findUnwiredModules)
38+
{PresetFull, maxFullPresetBytes, 80, 97}, // v8.4: 97 tools (+findUnwiredModules)
3939
}
4040

4141
for _, tt := range tests {

internal/mcp/tool_impls_unwired.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package mcp
2+
3+
import (
4+
"context"
5+
6+
"github.com/SimplyLiz/CodeMCP/internal/envelope"
7+
"github.com/SimplyLiz/CodeMCP/internal/query"
8+
)
9+
10+
// toolFindUnwiredModules finds exported symbols not reachable from entrypoints.
11+
func (s *MCPServer) toolFindUnwiredModules(params map[string]interface{}) (*envelope.Response, error) {
12+
opts := query.FindUnwiredModulesOptions{}
13+
14+
// Scope
15+
if scopeRaw, ok := params["scope"].([]interface{}); ok {
16+
for _, v := range scopeRaw {
17+
if str, ok := v.(string); ok {
18+
opts.Scope = append(opts.Scope, str)
19+
}
20+
}
21+
}
22+
23+
// Exclude patterns
24+
if patternsRaw, ok := params["excludePatterns"].([]interface{}); ok {
25+
for _, p := range patternsRaw {
26+
if str, ok := p.(string); ok {
27+
opts.ExcludePatterns = append(opts.ExcludePatterns, str)
28+
}
29+
}
30+
}
31+
32+
// Min confidence
33+
opts.MinConfidence = 0.80
34+
if v, ok := params["minConfidence"].(float64); ok {
35+
opts.MinConfidence = v
36+
}
37+
38+
// Include types
39+
if v, ok := params["includeTypes"].(bool); ok {
40+
opts.IncludeTypes = v
41+
}
42+
43+
// Max nodes (reachable set budget)
44+
opts.MaxNodes = 10000
45+
if v, ok := params["maxNodes"].(float64); ok {
46+
opts.MaxNodes = int(v)
47+
}
48+
49+
// Limit
50+
opts.Limit = 100
51+
if v, ok := params["limit"].(float64); ok {
52+
opts.Limit = int(v)
53+
}
54+
55+
ctx := context.Background()
56+
result, err := s.engine().FindUnwiredModules(ctx, opts)
57+
if err != nil {
58+
return nil, err
59+
}
60+
61+
resp := NewToolResponse().Data(result)
62+
if result != nil && result.ReachableCount == 0 {
63+
resp.Warning("No entrypoints detected. Ensure your project has main/server/CLI entry files.")
64+
}
65+
if result != nil && result.Partial {
66+
resp.Warning("Reachable set budget exhausted — results may be incomplete. Increase maxNodes for a full scan.")
67+
}
68+
69+
return resp.Build(), nil
70+
}

0 commit comments

Comments
 (0)