Skip to content

Commit d3ba914

Browse files
author
SqlRush
committed
Validate schema enum values
1 parent 38a3428 commit d3ba914

6 files changed

Lines changed: 96 additions & 5 deletions

File tree

docs/cc-100-roadmap.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,8 @@ M5 补充:WebSearch `query` schema 现在按官方 `min(2)` 约束拒绝单字
287287

288288
M5 补充:WebSearch domain filters 现在在 schema 层声明 array `items:string`,通用 tool schema validator 同步支持 `items` 校验;`allowed_domains`/`blocked_domains` 会拒绝空字符串、URL/port、非法 wildcard 和非域名 label。
289289

290+
M5 补充:通用 tool schema validator 现在支持 `enum`,可直接执行 Grep output mode、NotebookEdit edit mode/cell type、Todo status/priority、Task target/action、LSP severity 等工具 schema 的枚举契约。
291+
290292
M7 补充:scripted permission payload、dialog expectation、event、cancel-permission 和 dialog-result expectation 现在接受 `ID`/`ToolName`/`Actions``permissionID``requestID``toolUseID``operationID``operation``commandName``resourcePath``body``reasonText``allowedActions``buttons` 等相邻字段,并支持数字 request ID。
291293

292294
M6 补充:microcompact disk cache loader 和 prune 现在接受 digest 缺失但文件名已 keyed 的 cache entry,会用 `<digest>.json` 文件名作为 digest fallback,同时保留显式 digest mismatch 的 invalid-cache guard。

docs/claude-code-go-rewrite-plan.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ test/parity/ # golden tests against TS/official behavior
184184
- 本轮补充:WebSearch JSON result parser 现在支持 `pageUrl`/`targetUrl`/`source_url`/`formattedUrl` 等 URL aliases、`htmlTitle`/`htmlSnippet` 等 HTML 标记字段清理、嵌套 URL object,以及 `deepLinks`/`siteLinks` 子结果递归解析。
185185
- 本轮补充:WebSearch `query` schema 现在按官方 `min(2)` 约束拒绝单字符查询,通用 tool schema validator 同步支持 `minLength`,让工具定义可直接表达字符串最小长度契约。
186186
- 本轮补充:WebSearch domain filters 现在在 schema 层声明 array `items:string`,通用 tool schema validator 同步支持 `items` 校验;`allowed_domains`/`blocked_domains` 会拒绝空字符串、URL/port、非法 wildcard 和非域名 label。
187+
- 本轮补充:通用 tool schema validator 现在支持 `enum`,可直接执行 Grep output mode、NotebookEdit edit mode/cell type、Todo status/priority、Task target/action、LSP severity 等工具 schema 的枚举契约。
187188
- 本轮补充:WebFetch/WebSearch 输入解码现在兼容 `timeout``max_bytes`/`maxBytes``max_results`/`maxResults` 的 quoted semantic string 数值;WebSearch 也会按官方校验拒绝同一请求同时设置 `allowed_domains``blocked_domains`
188189
- 本轮补充:Grep 现在支持 whole-word 搜索参数 `word_regexp`/`wordRegexp`/`word-regexp`/`-w`,在 regex 和 fixed-string 模式下按词边界过滤匹配,并兼容 quoted boolean 输入。
189190
- 本轮补充:Grep 现在支持反向匹配参数 `invert_match`/`invertMatch`/`invert-match`/`-v``files_with_matches``content``count` 和 multiline 模式都会按非匹配行/未覆盖行输出,并兼容 quoted boolean 输入。

internal/tool/schema.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ func validateValue(schema contracts.JSONSchema, value any, path string) error {
2828
return fmt.Errorf("%s must be %s", path, strings.Join(types, " or "))
2929
}
3030
}
31+
if enumValues, ok := schemaEnumValues(schema["enum"]); ok {
32+
if !matchesEnumValue(value, enumValues) {
33+
return fmt.Errorf("%s must be one of %s", path, describeEnumValues(enumValues))
34+
}
35+
}
3136
if minLength, ok := intSchemaConstraint(schema["minLength"]); ok {
3237
text, ok := value.(string)
3338
if ok && utf8.RuneCountInString(text) < minLength {
@@ -117,6 +122,78 @@ func matchesAnyType(types []string, value any) bool {
117122
return false
118123
}
119124

125+
func schemaEnumValues(value any) ([]any, bool) {
126+
switch v := value.(type) {
127+
case []any:
128+
return v, len(v) > 0
129+
case []string:
130+
out := make([]any, 0, len(v))
131+
for _, item := range v {
132+
out = append(out, item)
133+
}
134+
return out, len(out) > 0
135+
default:
136+
return nil, false
137+
}
138+
}
139+
140+
func matchesEnumValue(value any, enumValues []any) bool {
141+
for _, candidate := range enumValues {
142+
if equalSchemaValue(value, candidate) {
143+
return true
144+
}
145+
}
146+
return false
147+
}
148+
149+
func equalSchemaValue(a any, b any) bool {
150+
if af, ok := schemaNumber(a); ok {
151+
if bf, ok := schemaNumber(b); ok {
152+
return af == bf
153+
}
154+
}
155+
switch av := a.(type) {
156+
case string:
157+
bv, ok := b.(string)
158+
return ok && av == bv
159+
case bool:
160+
bv, ok := b.(bool)
161+
return ok && av == bv
162+
case nil:
163+
return b == nil
164+
default:
165+
return fmt.Sprint(a) == fmt.Sprint(b)
166+
}
167+
}
168+
169+
func describeEnumValues(enumValues []any) string {
170+
parts := make([]string, 0, len(enumValues))
171+
for _, value := range enumValues {
172+
parts = append(parts, fmt.Sprint(value))
173+
}
174+
return strings.Join(parts, ", ")
175+
}
176+
177+
func schemaNumber(value any) (float64, bool) {
178+
switch v := value.(type) {
179+
case float64:
180+
return v, true
181+
case float32:
182+
return float64(v), true
183+
case int:
184+
return float64(v), true
185+
case int64:
186+
return float64(v), true
187+
case int32:
188+
return float64(v), true
189+
case json.Number:
190+
f, err := v.Float64()
191+
return f, err == nil
192+
default:
193+
return 0, false
194+
}
195+
}
196+
120197
func intSchemaConstraint(value any) (int, bool) {
121198
switch v := value.(type) {
122199
case int:

internal/tool/tool_test.go

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,10 @@ func TestValidateSchema(t *testing.T) {
3636
"type": "object",
3737
"required": []any{"path"},
3838
"properties": map[string]any{
39-
"path": map[string]any{"type": "string", "minLength": 2},
40-
"tags": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
39+
"path": map[string]any{"type": "string", "minLength": 2},
40+
"mode": map[string]any{"type": "string", "enum": []any{"read", "write"}},
41+
"count": map[string]any{"type": "integer", "enum": []any{1, 2}},
42+
"tags": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
4143
},
4244
}
4345
if err := ValidateSchema(schema, json.RawMessage(`{"path":3}`)); err == nil {
@@ -49,9 +51,18 @@ func TestValidateSchema(t *testing.T) {
4951
if err := ValidateSchema(schema, json.RawMessage(`{"path":"README.md","tags":[3]}`)); err == nil || !strings.Contains(err.Error(), "input.tags[0] must be string") {
5052
t.Fatalf("err = %v", err)
5153
}
54+
if err := ValidateSchema(schema, json.RawMessage(`{"path":"README.md","mode":"delete"}`)); err == nil || !strings.Contains(err.Error(), "input.mode must be one of read, write") {
55+
t.Fatalf("err = %v", err)
56+
}
57+
if err := ValidateSchema(schema, json.RawMessage(`{"path":"README.md","count":3}`)); err == nil || !strings.Contains(err.Error(), "input.count must be one of 1, 2") {
58+
t.Fatalf("err = %v", err)
59+
}
5260
if err := ValidateSchema(schema, json.RawMessage(`{"path":"README.md"}`)); err != nil {
5361
t.Fatal(err)
5462
}
63+
if err := ValidateSchema(schema, json.RawMessage(`{"path":"README.md","mode":"read","count":2}`)); err != nil {
64+
t.Fatal(err)
65+
}
5566
}
5667

5768
func TestExecutorRunsAllowedTool(t *testing.T) {

internal/tools/lsp/tools_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ func TestDiagnosticsToolNoSessionPath(t *testing.T) {
5252

5353
func TestDiagnosticsToolValidatesSeverity(t *testing.T) {
5454
err := NewDiagnosticsTool().Validate(tool.Context{}, json.RawMessage(`{"severity":"fatal"}`))
55-
if err == nil || !strings.Contains(err.Error(), "unsupported severity") {
55+
if err == nil || !strings.Contains(err.Error(), "input.severity must be one of error, warning, info, hint") {
5656
t.Fatalf("severity validation err = %v", err)
5757
}
5858
}

internal/tools/todo/tools_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,12 +148,12 @@ func TestTodoWriteValidatesInput(t *testing.T) {
148148
{
149149
name: "invalid status",
150150
input: `{"todos":[{"id":"1","content":"x","status":"blocked","priority":"low"}]}`,
151-
want: "status must be one of pending, in_progress, or completed",
151+
want: "input.todos[0].status must be one of pending, in_progress, completed",
152152
},
153153
{
154154
name: "invalid priority",
155155
input: `{"todos":[{"id":"1","content":"x","status":"pending","priority":"urgent"}]}`,
156-
want: "priority must be one of high, medium, or low",
156+
want: "input.todos[0].priority must be one of high, medium, low",
157157
},
158158
{
159159
name: "duplicate id",

0 commit comments

Comments
 (0)