Skip to content

Commit 136a7d8

Browse files
SimplyLizclaude
andcommitted
feat(query): add symbolExists — exact-match boolean oracle for LLM grounding
Adds a new MCP tool and query engine method that answers "does this bare symbol name exist in the index?" using a direct WHERE-clause query against symbols_fts_content, bypassing FTS5 tokenisation entirely. This makes class methods (saveReport, trackUsage) and object-property declarations (setApiKey: t.procedure…) reliably findable by bare leaf name — the cases that caused ~20–30% false-rejection rates in ArchReview's persona-swarm validator when it used searchSymbols + client-side exact comparison. Returns { exists, matches, kinds, receivers?, staleIndex? } — a cheap boolean payload with no locations or ranking noise. Adds a note to the searchSymbols description pointing callers to symbolExists for authoritative lookups. Wire into all presets (core + review, refactor, federation, docs, ops). Acceptance criteria from the backlog spec all pass (11 tests). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent cf7ff98 commit 136a7d8

7 files changed

Lines changed: 402 additions & 15 deletions

File tree

internal/mcp/presets.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ var Presets = map[string][]string{
3939

4040
// Discovery & Search (granular fallback)
4141
"searchSymbols",
42+
"symbolExists",
4243
"getSymbol",
4344

4445
// Navigation & Understanding (granular fallback)
@@ -77,7 +78,7 @@ var Presets = map[string][]string{
7778
PresetReview: {
7879
// Core tools
7980
"explore", "understand", "prepareChange", "batchGet", "batchSearch",
80-
"searchSymbols", "getSymbol", "explainSymbol", "explainFile", "explainPath",
81+
"searchSymbols", "symbolExists", "getSymbol", "explainSymbol", "explainFile", "explainPath",
8182
"findReferences", "getCallGraph", "traceUsage",
8283
"getArchitecture", "getModuleOverview", "getModuleResponsibilities", "listKeyConcepts",
8384
"analyzeImpact", "getHotspots", "exportForLLM",
@@ -106,7 +107,7 @@ var Presets = map[string][]string{
106107
PresetRefactor: {
107108
// Core tools
108109
"explore", "understand", "prepareChange", "batchGet", "batchSearch",
109-
"searchSymbols", "getSymbol", "explainSymbol", "explainFile", "explainPath",
110+
"searchSymbols", "symbolExists", "getSymbol", "explainSymbol", "explainFile", "explainPath",
110111
"findReferences", "getCallGraph", "traceUsage",
111112
"getArchitecture", "getModuleOverview", "getModuleResponsibilities", "listKeyConcepts",
112113
"analyzeImpact", "getHotspots", "exportForLLM",
@@ -136,7 +137,7 @@ var Presets = map[string][]string{
136137
PresetFederation: {
137138
// Core tools
138139
"explore", "understand", "prepareChange", "batchGet", "batchSearch",
139-
"searchSymbols", "getSymbol", "explainSymbol", "explainFile", "explainPath",
140+
"searchSymbols", "symbolExists", "getSymbol", "explainSymbol", "explainFile", "explainPath",
140141
"findReferences", "getCallGraph", "traceUsage",
141142
"getArchitecture", "getModuleOverview", "getModuleResponsibilities", "listKeyConcepts",
142143
"analyzeImpact", "getHotspots", "exportForLLM",
@@ -170,7 +171,7 @@ var Presets = map[string][]string{
170171
PresetDocs: {
171172
// Core tools
172173
"explore", "understand", "prepareChange", "batchGet", "batchSearch",
173-
"searchSymbols", "getSymbol", "explainSymbol", "explainFile", "explainPath",
174+
"searchSymbols", "symbolExists", "getSymbol", "explainSymbol", "explainFile", "explainPath",
174175
"findReferences", "getCallGraph", "traceUsage",
175176
"getArchitecture", "getModuleOverview", "getModuleResponsibilities", "listKeyConcepts",
176177
"analyzeImpact", "getHotspots", "exportForLLM",
@@ -192,7 +193,7 @@ var Presets = map[string][]string{
192193
PresetOps: {
193194
// Core tools
194195
"explore", "understand", "prepareChange", "batchGet", "batchSearch",
195-
"searchSymbols", "getSymbol", "explainSymbol", "explainFile", "explainPath",
196+
"searchSymbols", "symbolExists", "getSymbol", "explainSymbol", "explainFile", "explainPath",
196197
"findReferences", "getCallGraph", "traceUsage",
197198
"getArchitecture", "getModuleOverview", "getModuleResponsibilities", "listKeyConcepts",
198199
"analyzeImpact", "getHotspots", "exportForLLM",
@@ -263,6 +264,7 @@ var coreToolOrder = []string{
263264
"batchSearch",
264265
// Granular tools (fallback)
265266
"searchSymbols",
267+
"symbolExists",
266268
"getSymbol",
267269
"explainSymbol",
268270
"explainFile",

internal/mcp/presets_test.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,14 @@ func TestPresetFiltering(t *testing.T) {
1414
// Test core preset (default)
1515
// v8.3: Core now includes explainPath, getModuleResponsibilities, exportForLLM
1616
coreTools := server.GetFilteredTools()
17-
if len(coreTools) != 24 {
18-
t.Errorf("expected 24 core tools, got %d", len(coreTools))
17+
if len(coreTools) != 25 {
18+
t.Errorf("expected 25 core tools, got %d", len(coreTools))
1919
}
2020

2121
// Verify compound tools come first (preferred for AI workflows)
2222
expectedFirst := []string{
2323
"explore", "understand", "prepareChange", "batchGet", "batchSearch",
24-
"searchSymbols", "getSymbol", "explainSymbol", "explainFile", "explainPath",
24+
"searchSymbols", "symbolExists", "getSymbol", "explainSymbol", "explainFile", "explainPath",
2525
"findReferences", "getCallGraph", "traceUsage",
2626
"getArchitecture", "getModuleOverview", "getModuleResponsibilities", "listKeyConcepts",
2727
"analyzeImpact", "getHotspots", "exportForLLM",
@@ -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.5: +3 Cartographer (shotgunSurgery, evolution, blastRadius) +3 LIP annotation tools = 107
46-
if len(fullTools) != 107 {
47-
t.Errorf("expected 107 full tools, got %d", len(fullTools))
45+
// v8.5: +3 Cartographer (shotgunSurgery, evolution, blastRadius) +3 LIP annotation tools = 107; +1 symbolExists = 108; +1 (full includes the expanded presets) = 109
46+
if len(fullTools) != 109 {
47+
t.Errorf("expected 109 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: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,9 @@ func TestToolsListTokenBudget(t *testing.T) {
3333
minTools int // Ensure we don't accidentally drop tools
3434
maxTools int
3535
}{
36-
{PresetCore, maxCorePresetBytes, 20, 24}, // v8.3: 24 tools (+explainPath, responsibilities, exportForLLM)
37-
{PresetReview, maxReviewPresetBytes, 30, 41}, // v8.4: 41 tools (+findUnwiredModules)
38-
{PresetFull, maxFullPresetBytes, 80, 107}, // v8.5: 107 tools (+3 Cartographer, +3 LIP annotation)
36+
{PresetCore, maxCorePresetBytes, 20, 25}, // v8.3: 24 tools (+explainPath, responsibilities, exportForLLM); +1 symbolExists = 25
37+
{PresetReview, maxReviewPresetBytes, 30, 42}, // v8.4: 41 tools (+findUnwiredModules); +1 symbolExists = 42
38+
{PresetFull, maxFullPresetBytes, 80, 109}, // v8.5: 107 tools (+3 Cartographer, +3 LIP annotation); +1 symbolExists in all presets = 109
3939
}
4040

4141
for _, tt := range tests {

internal/mcp/tool_impls.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -593,6 +593,56 @@ func (s *MCPServer) toolSearchSymbols(params map[string]interface{}) (*envelope.
593593
Build(), nil
594594
}
595595

596+
// toolSymbolExists implements the symbolExists tool.
597+
func (s *MCPServer) toolSymbolExists(params map[string]interface{}) (*envelope.Response, error) {
598+
name, ok := params["name"].(string)
599+
if !ok || name == "" {
600+
return nil, errors.NewInvalidParameterError("name", "")
601+
}
602+
603+
var kinds []string
604+
if kindsVal, ok := params["kinds"].([]interface{}); ok {
605+
for _, k := range kindsVal {
606+
if kStr, ok := k.(string); ok {
607+
kinds = append(kinds, kStr)
608+
}
609+
}
610+
}
611+
612+
scope, _ := params["scope"].(string)
613+
includeExternal, _ := params["includeExternal"].(bool)
614+
615+
ctx := context.Background()
616+
opts := query.SymbolExistsOptions{
617+
Name: name,
618+
Kinds: kinds,
619+
Scope: scope,
620+
IncludeExternal: includeExternal,
621+
}
622+
623+
result, err := s.engine().SymbolExists(ctx, opts)
624+
if err != nil {
625+
return nil, errors.NewOperationError("symbol exists", err)
626+
}
627+
628+
data := map[string]interface{}{
629+
"exists": result.Exists,
630+
"matches": result.Matches,
631+
"kinds": result.Kinds,
632+
}
633+
if len(result.Receivers) > 0 {
634+
data["receivers"] = result.Receivers
635+
}
636+
if result.StaleIndex {
637+
data["staleIndex"] = result.StaleIndex
638+
}
639+
640+
return NewToolResponse().
641+
Data(data).
642+
WithProvenance(result.Provenance).
643+
Build(), nil
644+
}
645+
596646
// toolFindReferences implements the findReferences tool
597647
func (s *MCPServer) toolFindReferences(params map[string]interface{}) (*envelope.Response, error) {
598648
timer := NewWideResultTimer()

internal/mcp/tools.go

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ func (s *MCPServer) GetToolDefinitions() []Tool {
107107
},
108108
{
109109
Name: "searchSymbols",
110-
Description: "Semantic code search returning symbol types, locations, and relationships—more accurate than text-based grep/find.",
110+
Description: "Semantic code search returning symbol types, locations, and relationships—more accurate than text-based grep/find. Note: may not match class methods or record properties by bare name — use symbolExists for authoritative boolean lookups.",
111111
InputSchema: map[string]interface{}{
112112
"type": "object",
113113
"properties": map[string]interface{}{
@@ -148,6 +148,34 @@ func (s *MCPServer) GetToolDefinitions() []Tool {
148148
"required": []string{"query"},
149149
},
150150
},
151+
{
152+
Name: "symbolExists",
153+
Description: "Boolean oracle for LLM grounding: answers whether a bare symbol name has any declaration in the index. Uses exact-match (not FTS ranking) so class methods and object-property declarations are found reliably. Returns exists, matches count, distinct kinds, and receiver names for methods/properties. Cheaper than searchSymbols — no locations, no ranking.",
154+
InputSchema: map[string]interface{}{
155+
"type": "object",
156+
"properties": map[string]interface{}{
157+
"name": map[string]interface{}{
158+
"type": "string",
159+
"description": "Bare symbol name to look up (e.g. \"saveReport\", \"ENV_PATH\")",
160+
},
161+
"kinds": map[string]interface{}{
162+
"type": "array",
163+
"items": map[string]interface{}{"type": "string"},
164+
"description": "Optional kind filter (e.g. [\"method\", \"function\", \"class\", \"property\"])",
165+
},
166+
"scope": map[string]interface{}{
167+
"type": "string",
168+
"description": "Optional file-path prefix to restrict search (e.g. \"packages/server/src/\")",
169+
},
170+
"includeExternal": map[string]interface{}{
171+
"type": "boolean",
172+
"default": false,
173+
"description": "Include symbols from node_modules (default false)",
174+
},
175+
},
176+
"required": []string{"name"},
177+
},
178+
},
151179
{
152180
Name: "listSymbols",
153181
Description: "Bulk list symbols in a scope without search query. Returns functions, types, and classes with body ranges and complexity metrics (lines, endLine, cyclomatic, cognitive). Use for complete symbol inventory — no search query needed.",
@@ -2731,6 +2759,7 @@ func (s *MCPServer) RegisterTools() {
27312759
s.tools["expandToolset"] = s.toolExpandToolset
27322760
s.tools["getSymbol"] = s.toolGetSymbol
27332761
s.tools["searchSymbols"] = s.toolSearchSymbols
2762+
s.tools["symbolExists"] = s.toolSymbolExists
27342763
s.tools["listSymbols"] = s.toolListSymbols
27352764
s.tools["getSymbolGraph"] = s.toolGetSymbolGraph
27362765
s.tools["findReferences"] = s.toolFindReferences

internal/query/symbol_exists.go

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
package query
2+
3+
import (
4+
"context"
5+
"sort"
6+
"strings"
7+
"time"
8+
9+
"github.com/SimplyLiz/CodeMCP/internal/errors"
10+
)
11+
12+
// SymbolExistsOptions is the input for SymbolExists.
13+
type SymbolExistsOptions struct {
14+
Name string
15+
Kinds []string
16+
Scope string
17+
IncludeExternal bool
18+
}
19+
20+
// SymbolExistsResult is the response for SymbolExists.
21+
type SymbolExistsResult struct {
22+
Exists bool `json:"exists"`
23+
Matches int `json:"matches"`
24+
Kinds []string `json:"kinds"`
25+
Receivers []string `json:"receivers,omitempty"`
26+
StaleIndex bool `json:"staleIndex,omitempty"`
27+
Provenance *Provenance `json:"provenance"`
28+
}
29+
30+
// SymbolExists answers whether a bare symbol name has any declaration in the index.
31+
// Unlike SearchSymbols it queries symbols_fts_content directly with an exact WHERE
32+
// clause, bypassing FTS5 tokenisation — so class methods and object-property
33+
// declarations whose bare leaf name never surfaces through FTS ranking are found
34+
// reliably.
35+
func (e *Engine) SymbolExists(ctx context.Context, opts SymbolExistsOptions) (*SymbolExistsResult, error) {
36+
startTime := time.Now()
37+
38+
if opts.Name == "" {
39+
return nil, errors.NewInvalidParameterError("name", "name is required")
40+
}
41+
42+
repoState, err := e.GetRepoState(ctx, "head")
43+
if err != nil {
44+
return nil, e.wrapError(err, errors.InternalError)
45+
}
46+
47+
notFound := func(reason string) *SymbolExistsResult {
48+
return &SymbolExistsResult{
49+
Exists: false,
50+
Matches: 0,
51+
Kinds: []string{},
52+
Provenance: e.buildProvenance(repoState, "head", startTime, nil,
53+
CompletenessInfo{Score: 0.5, Reason: reason}),
54+
}
55+
}
56+
57+
if e.db == nil {
58+
return notFound("db-unavailable"), nil
59+
}
60+
61+
sqlStr := `SELECT name, kind, COALESCE(signature, '') FROM symbols_fts_content WHERE name = ?`
62+
args := []interface{}{opts.Name}
63+
64+
if opts.Scope != "" {
65+
sqlStr += ` AND file_path LIKE ?`
66+
args = append(args, opts.Scope+"%")
67+
}
68+
69+
if !opts.IncludeExternal {
70+
sqlStr += ` AND file_path NOT LIKE '%node_modules%'`
71+
}
72+
73+
if len(opts.Kinds) > 0 {
74+
placeholders := strings.Repeat("?,", len(opts.Kinds))
75+
placeholders = placeholders[:len(placeholders)-1]
76+
sqlStr += ` AND kind IN (` + placeholders + `)`
77+
for _, k := range opts.Kinds {
78+
args = append(args, k)
79+
}
80+
}
81+
82+
rows, err := e.db.Query(sqlStr, args...)
83+
if err != nil {
84+
// Content table may not exist yet (index not yet populated); treat as not found.
85+
return notFound("fts-unavailable"), nil
86+
}
87+
defer rows.Close() //nolint:errcheck
88+
89+
kindsSet := map[string]bool{}
90+
receiversSet := map[string]bool{}
91+
matchCount := 0
92+
93+
for rows.Next() {
94+
var name, kind, signature string
95+
if scanErr := rows.Scan(&name, &kind, &signature); scanErr != nil {
96+
continue
97+
}
98+
matchCount++
99+
if kind != "" {
100+
kindsSet[kind] = true
101+
}
102+
// signature is "ReceiverName.leafName" for methods and properties.
103+
if strings.HasSuffix(signature, "."+name) {
104+
receiver := signature[:len(signature)-len("."+name)]
105+
if receiver != "" {
106+
receiversSet[receiver] = true
107+
}
108+
}
109+
}
110+
if rowsErr := rows.Err(); rowsErr != nil {
111+
return nil, e.wrapError(rowsErr, errors.InternalError)
112+
}
113+
114+
kinds := make([]string, 0, len(kindsSet))
115+
for k := range kindsSet {
116+
kinds = append(kinds, k)
117+
}
118+
sort.Strings(kinds)
119+
120+
var receivers []string
121+
if len(receiversSet) > 0 {
122+
receivers = make([]string, 0, len(receiversSet))
123+
for r := range receiversSet {
124+
receivers = append(receivers, r)
125+
}
126+
sort.Strings(receivers)
127+
}
128+
129+
return &SymbolExistsResult{
130+
Exists: matchCount > 0,
131+
Matches: matchCount,
132+
Kinds: kinds,
133+
Receivers: receivers,
134+
StaleIndex: repoState.Dirty,
135+
Provenance: e.buildProvenance(repoState, "head", startTime, nil,
136+
CompletenessInfo{Score: 1.0, Reason: "exact-match"}),
137+
}, nil
138+
}

0 commit comments

Comments
 (0)