From 03dacde83e4ab1d706ee377d12fd127d35e0390a Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Wed, 16 Sep 2026 03:05:16 +0530 Subject: [PATCH] refactor(tool): SchemaProperty minimum/maximum; migrate FuzzyFind Extend SchemaProperty with Minimum/Maximum (interface{}, like Default, so literals survive verbatim) plus an advisory range check in validateSchema matching the existing soft-skip philosophy. Migrate FuzzyFindTool to a typed FuzzyFindInput + Schema()-backed Parameters with parity tests locking the limit bounds (1/100). Error text for malformed input changes from 'invalid input' to the standard DecodeInput message; no test asserted the old string. --- internal/tool/fuzzy_find_tool.go | 58 ++++++++++++++++++------------ internal/tool/schema_batch_test.go | 35 ++++++++++++++++++ internal/tool/tool.go | 11 ++++++ internal/tool/validate_input.go | 37 +++++++++++++++++++ 4 files changed, 118 insertions(+), 23 deletions(-) diff --git a/internal/tool/fuzzy_find_tool.go b/internal/tool/fuzzy_find_tool.go index 18604e7cd..8489a8379 100644 --- a/internal/tool/fuzzy_find_tool.go +++ b/internal/tool/fuzzy_find_tool.go @@ -24,37 +24,49 @@ func (FuzzyFindTool) Description() string { return "Fuzzy-search a project for files by name. Handles partial names, camel-case abbreviations (CP→CachePlanner), and multi-word queries (cache gate→cache_gate.go). Returns ranked paths." } -func (FuzzyFindTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "query": map[string]interface{}{ - "type": "string", - "description": "Fuzzy query: full or partial file name, camel-case abbreviation, or space-separated terms.", +// FuzzyFindInput is the typed input for FuzzyFindTool. +type FuzzyFindInput struct { + Query string `json:"query"` + Path string `json:"path"` + Limit int `json:"limit"` +} + +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. The limit bounds are preserved verbatim. +func (FuzzyFindTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "query": { + Type: "string", + Description: "Fuzzy query: full or partial file name, camel-case abbreviation, or space-separated terms.", }, - "path": map[string]interface{}{ - "type": "string", - "description": "Project directory (default: session working directory).", + "path": { + Type: "string", + Description: "Project directory (default: session working directory).", }, - "limit": map[string]interface{}{ - "type": "integer", - "minimum": 1, - "maximum": 100, - "description": "Maximum results (default 20).", + "limit": { + Type: "integer", + Minimum: 1, + Maximum: 100, + Description: "Maximum results (default 20).", }, }, - "required": []string{"query"}, + Required: []string{"query"}, } } +func (FuzzyFindTool) Parameters() map[string]interface{} { + return fuzzyFindSchema.ToJSONSchema() +} + +// fuzzyFindSchema is the single source of truth for FuzzyFind's input schema. +var fuzzyFindSchema = FuzzyFindTool{}.Schema() + func (FuzzyFindTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var params struct { - Query string `json:"query"` - Path string `json:"path"` - Limit int `json:"limit"` - } - if err := json.Unmarshal(input, ¶ms); err != nil { - return "", fmt.Errorf("invalid input: %w", err) + params, err := DecodeInput[FuzzyFindInput]("FuzzyFind", input) + if err != nil { + return "", err } if strings.TrimSpace(params.Query) == "" { return "", fmt.Errorf("query is required") diff --git a/internal/tool/schema_batch_test.go b/internal/tool/schema_batch_test.go index f2f4466ec..92c5d0e56 100644 --- a/internal/tool/schema_batch_test.go +++ b/internal/tool/schema_batch_test.go @@ -83,3 +83,38 @@ func TestScreenshotSchemaProvider(t *testing.T) { t.Fatalf("required = %v, want [url]", ScreenshotTool{}.Parameters()["required"]) } } + +func TestFuzzyFindSchemaProvider(t *testing.T) { + var _ SchemaProvider = FuzzyFindTool{} + props := schemaProps(t, FuzzyFindTool{}.Parameters()) + limit, ok := props["limit"].(map[string]interface{}) + if !ok || limit["type"] != "integer" { + t.Fatalf("limit prop = %v, want integer type", props["limit"]) + } + // Bounds must survive the migration verbatim. + if limit["minimum"] != 1 || limit["maximum"] != 100 { + t.Fatalf("limit bounds = %v/%v, want 1/100", limit["minimum"], limit["maximum"]) + } + req, _ := FuzzyFindTool{}.Parameters()["required"].([]string) + if len(req) != 1 || req[0] != "query" { + t.Fatalf("required = %v, want [query]", FuzzyFindTool{}.Parameters()["required"]) + } +} + +func TestMatchesRange(t *testing.T) { + if !matchesRange("x", 1, 100) { + t.Fatal("non-numeric value should pass (only numbers are ranged)") + } + if !matchesRange(float64(50), 1, 100) { + t.Fatal("50 should be in range") + } + if matchesRange(float64(0), 1, 100) { + t.Fatal("0 should be out of range") + } + if matchesRange(float64(101), 1, 100) { + t.Fatal("101 should be out of range") + } + if !matchesRange(float64(5), nil, nil) { + t.Fatal("no bounds should pass") + } +} diff --git a/internal/tool/tool.go b/internal/tool/tool.go index 976d2e4f7..15b72e9b9 100644 --- a/internal/tool/tool.go +++ b/internal/tool/tool.go @@ -58,6 +58,11 @@ type SchemaProperty struct { Enum []interface{} `json:"enum,omitempty"` // Items describes the element type for array fields. Items *SchemaProperty `json:"items,omitempty"` + // Minimum/Maximum constrain numeric fields. Kept as interface{} (like + // Default) so authors write plain literals and the wire output matches + // hand-written schemas exactly. + Minimum interface{} `json:"minimum,omitempty"` + Maximum interface{} `json:"maximum,omitempty"` } // SchemaProvider is an optional interface tools can implement to expose a typed @@ -97,6 +102,12 @@ func (p SchemaProperty) toMap() map[string]interface{} { if len(p.Enum) > 0 { m["enum"] = p.Enum } + if p.Minimum != nil { + m["minimum"] = p.Minimum + } + if p.Maximum != nil { + m["maximum"] = p.Maximum + } if p.Items != nil { m["items"] = p.Items.toMap() } diff --git a/internal/tool/validate_input.go b/internal/tool/validate_input.go index d83873c2c..13598c0f6 100644 --- a/internal/tool/validate_input.go +++ b/internal/tool/validate_input.go @@ -61,6 +61,43 @@ func validateSchema(input map[string]interface{}, schema ToolSchema, name string if len(prop.Enum) > 0 && !matchesEnum(value, prop.Enum) { continue } + if (prop.Minimum != nil || prop.Maximum != nil) && !matchesRange(value, prop.Minimum, prop.Maximum) { + // Same softening as above: advisory only, caught by unit tests. + continue + } + } +} + +func matchesRange(value, minimum, maximum interface{}) bool { + n, ok := value.(float64) // JSON numbers decode as float64 + if !ok { + return true + } + if minimum != nil { + if min, ok := toFloat(minimum); ok && n < min { + return false + } + } + if maximum != nil { + if max, ok := toFloat(maximum); ok && n > max { + return false + } + } + return true +} + +func toFloat(v interface{}) (float64, bool) { + switch n := v.(type) { + case float64: + return n, true + case float32: + return float64(n), true + case int: + return float64(n), true + case int64: + return float64(n), true + default: + return 0, false } }