fix: preserve runtime config validation compatibility

This commit is contained in:
YeonGyu-Kim
2026-06-08 15:01:48 +09:00
parent d58197cca4
commit 05f0201ec7

View File

@@ -92,6 +92,8 @@ enum FieldType {
Bool, Bool,
Object, Object,
StringArray, StringArray,
HookArray,
RulesImport,
Number, Number,
} }
@@ -102,6 +104,8 @@ impl FieldType {
Self::Bool => "a boolean", Self::Bool => "a boolean",
Self::Object => "an object", Self::Object => "an object",
Self::StringArray => "an array of strings", Self::StringArray => "an array of strings",
Self::RulesImport => "a string or an array of strings",
Self::HookArray => "an array of strings or hook objects",
Self::Number => "a number", Self::Number => "a number",
} }
} }
@@ -114,6 +118,13 @@ impl FieldType {
Self::StringArray => value Self::StringArray => value
.as_array() .as_array()
.is_some_and(|arr| arr.iter().all(|v| v.as_str().is_some())), .is_some_and(|arr| arr.iter().all(|v| v.as_str().is_some())),
Self::HookArray => true,
Self::RulesImport => {
value.as_str().is_some()
|| value
.as_array()
.is_some_and(|arr| arr.iter().all(|v| v.as_str().is_some()))
}
Self::Number => value.as_i64().is_some(), Self::Number => value.as_i64().is_some(),
} }
} }
@@ -201,6 +212,10 @@ const TOP_LEVEL_FIELDS: &[FieldSpec] = &[
name: "provider", name: "provider",
expected: FieldType::Object, expected: FieldType::Object,
}, },
FieldSpec {
name: "rulesImport",
expected: FieldType::RulesImport,
},
FieldSpec { FieldSpec {
name: "subagentModel", name: "subagentModel",
expected: FieldType::String, expected: FieldType::String,
@@ -210,15 +225,15 @@ const TOP_LEVEL_FIELDS: &[FieldSpec] = &[
const HOOKS_FIELDS: &[FieldSpec] = &[ const HOOKS_FIELDS: &[FieldSpec] = &[
FieldSpec { FieldSpec {
name: "PreToolUse", name: "PreToolUse",
expected: FieldType::StringArray, expected: FieldType::HookArray,
}, },
FieldSpec { FieldSpec {
name: "PostToolUse", name: "PostToolUse",
expected: FieldType::StringArray, expected: FieldType::HookArray,
}, },
FieldSpec { FieldSpec {
name: "PostToolUseFailure", name: "PostToolUseFailure",
expected: FieldType::StringArray, expected: FieldType::HookArray,
}, },
]; ];
@@ -410,9 +425,8 @@ fn validate_object_keys(
} else if DEPRECATED_FIELDS.iter().any(|d| d.name == key) { } else if DEPRECATED_FIELDS.iter().any(|d| d.name == key) {
// Deprecated key — handled separately, not an unknown-key error. // Deprecated key — handled separately, not an unknown-key error.
} else { } else {
// Unknown key.
let suggestion = suggest_field(key, &known_names); let suggestion = suggest_field(key, &known_names);
result.errors.push(ConfigDiagnostic { result.warnings.push(ConfigDiagnostic {
path: path_display.to_string(), path: path_display.to_string(),
field: field_path, field: field_path,
line: find_key_line(source, key), line: find_key_line(source, key),
@@ -591,10 +605,11 @@ mod tests {
let result = validate_config_file(object, source, &test_path()); let result = validate_config_file(object, source, &test_path());
// then // then
assert_eq!(result.errors.len(), 1); assert!(result.errors.is_empty());
assert_eq!(result.errors[0].field, "unknownField"); assert_eq!(result.warnings.len(), 1);
assert_eq!(result.warnings[0].field, "unknownField");
assert!(matches!( assert!(matches!(
result.errors[0].kind, result.warnings[0].kind,
DiagnosticKind::UnknownKey { .. } DiagnosticKind::UnknownKey { .. }
)); ));
} }
@@ -674,9 +689,10 @@ mod tests {
let result = validate_config_file(object, source, &test_path()); let result = validate_config_file(object, source, &test_path());
// then // then
assert_eq!(result.errors.len(), 1); assert!(result.errors.is_empty());
assert_eq!(result.errors[0].line, Some(3)); assert_eq!(result.warnings.len(), 1);
assert_eq!(result.errors[0].field, "badKey"); assert_eq!(result.warnings[0].line, Some(3));
assert_eq!(result.warnings[0].field, "badKey");
} }
#[test] #[test]
@@ -697,7 +713,7 @@ mod tests {
#[test] #[test]
fn validates_nested_hooks_keys() { fn validates_nested_hooks_keys() {
// given // given
let source = r#"{"hooks": {"PreToolUse": ["cmd"], "BadHook": ["x"]}}"#; let source = r#"{"hooks": {"PreToolUse": [{"hooks":[{"type":"command","command":"cmd"}]}], "BadHook": ["x"]}}"#;
let parsed = JsonValue::parse(source).expect("valid json"); let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object"); let object = parsed.as_object().expect("object");
@@ -705,8 +721,64 @@ mod tests {
let result = validate_config_file(object, source, &test_path()); let result = validate_config_file(object, source, &test_path());
// then // then
assert!(result.errors.is_empty());
assert_eq!(
result.warnings.len(),
1,
"expected only the unknown key warning, got {:?}",
result.warnings
);
assert_eq!(result.warnings[0].field, "hooks.BadHook");
}
#[test]
fn validates_object_style_hook_entries() {
let source = r#"{"hooks":{"PreToolUse":["legacy",{"matcher":"Bash","hooks":[{"type":"command","command":"echo ok"}]}]}}"#;
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
let result = validate_config_file(object, source, &test_path());
assert!(result.errors.is_empty(), "{:?}", result.errors);
}
#[test]
fn allows_wrong_hook_entry_types_for_partial_runtime_validation_441() {
let source = r#"{"hooks":{"PreToolUse":[42]}}"#;
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
let result = validate_config_file(object, source, &test_path());
assert!(result.errors.is_empty(), "{:?}", result.errors);
}
#[test]
fn validates_rules_import_string_and_array_forms() {
for source in [
r#"{"rulesImport":"auto"}"#,
r#"{"rulesImport":"none"}"#,
r#"{"rulesImport":["cursor","copilot"]}"#,
] {
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
let result = validate_config_file(object, source, &test_path());
assert!(result.errors.is_empty(), "{source}: {:?}", result.errors);
}
}
#[test]
fn rejects_rules_import_wrong_type() {
let source = r#"{"rulesImport":42}"#;
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
let result = validate_config_file(object, source, &test_path());
assert_eq!(result.errors.len(), 1); assert_eq!(result.errors.len(), 1);
assert_eq!(result.errors[0].field, "hooks.BadHook"); assert_eq!(result.errors[0].field, "rulesImport");
} }
#[test] #[test]
@@ -720,8 +792,9 @@ mod tests {
let result = validate_config_file(object, source, &test_path()); let result = validate_config_file(object, source, &test_path());
// then // then
assert_eq!(result.errors.len(), 1); assert!(result.errors.is_empty());
assert_eq!(result.errors[0].field, "permissions.denyAll"); assert_eq!(result.warnings.len(), 1);
assert_eq!(result.warnings[0].field, "permissions.denyAll");
} }
#[test] #[test]
@@ -735,8 +808,9 @@ mod tests {
let result = validate_config_file(object, source, &test_path()); let result = validate_config_file(object, source, &test_path());
// then // then
assert_eq!(result.errors.len(), 1); assert!(result.errors.is_empty());
assert_eq!(result.errors[0].field, "sandbox.containerMode"); assert_eq!(result.warnings.len(), 1);
assert_eq!(result.warnings[0].field, "sandbox.containerMode");
} }
#[test] #[test]
@@ -750,8 +824,9 @@ mod tests {
let result = validate_config_file(object, source, &test_path()); let result = validate_config_file(object, source, &test_path());
// then // then
assert_eq!(result.errors.len(), 1); assert!(result.errors.is_empty());
assert_eq!(result.errors[0].field, "plugins.autoUpdate"); assert_eq!(result.warnings.len(), 1);
assert_eq!(result.warnings[0].field, "plugins.autoUpdate");
} }
#[test] #[test]
@@ -765,8 +840,9 @@ mod tests {
let result = validate_config_file(object, source, &test_path()); let result = validate_config_file(object, source, &test_path());
// then // then
assert_eq!(result.errors.len(), 1); assert!(result.errors.is_empty());
assert_eq!(result.errors[0].field, "oauth.secret"); assert_eq!(result.warnings.len(), 1);
assert_eq!(result.warnings[0].field, "oauth.secret");
} }
#[test] #[test]
@@ -774,7 +850,7 @@ mod tests {
// given // given
let source = r#"{ let source = r#"{
"model": "opus", "model": "opus",
"hooks": {"PreToolUse": ["guard"]}, "hooks": {"PreToolUse": [{"hooks":[{"type":"command","command":"guard"}]}]},
"permissions": {"defaultMode": "plan", "allow": ["Read"]}, "permissions": {"defaultMode": "plan", "allow": ["Read"]},
"mcpServers": {}, "mcpServers": {},
"sandbox": {"enabled": false} "sandbox": {"enabled": false}
@@ -801,8 +877,9 @@ mod tests {
let result = validate_config_file(object, source, &test_path()); let result = validate_config_file(object, source, &test_path());
// then // then
assert_eq!(result.errors.len(), 1); assert!(result.errors.is_empty());
match &result.errors[0].kind { assert_eq!(result.warnings.len(), 1);
match &result.warnings[0].kind {
DiagnosticKind::UnknownKey { DiagnosticKind::UnknownKey {
suggestion: Some(s), suggestion: Some(s),
} => assert_eq!(s, "model"), } => assert_eq!(s, "model"),
@@ -813,7 +890,7 @@ mod tests {
#[test] #[test]
fn format_diagnostics_includes_all_entries() { fn format_diagnostics_includes_all_entries() {
// given // given
let source = r#"{"permissionMode": "plan", "badKey": 1}"#; let source = r#"{"model": 42, "badKey": 1}"#;
let parsed = JsonValue::parse(source).expect("valid json"); let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object"); let object = parsed.as_object().expect("object");
let result = validate_config_file(object, source, &test_path()); let result = validate_config_file(object, source, &test_path());
@@ -825,7 +902,7 @@ mod tests {
assert!(output.contains("warning:")); assert!(output.contains("warning:"));
assert!(output.contains("error:")); assert!(output.contains("error:"));
assert!(output.contains("badKey")); assert!(output.contains("badKey"));
assert!(output.contains("permissionMode")); assert!(output.contains("model"));
} }
#[test] #[test]