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,
Object,
StringArray,
HookArray,
RulesImport,
Number,
}
@@ -102,6 +104,8 @@ impl FieldType {
Self::Bool => "a boolean",
Self::Object => "an object",
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",
}
}
@@ -114,6 +118,13 @@ impl FieldType {
Self::StringArray => value
.as_array()
.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(),
}
}
@@ -201,6 +212,10 @@ const TOP_LEVEL_FIELDS: &[FieldSpec] = &[
name: "provider",
expected: FieldType::Object,
},
FieldSpec {
name: "rulesImport",
expected: FieldType::RulesImport,
},
FieldSpec {
name: "subagentModel",
expected: FieldType::String,
@@ -210,15 +225,15 @@ const TOP_LEVEL_FIELDS: &[FieldSpec] = &[
const HOOKS_FIELDS: &[FieldSpec] = &[
FieldSpec {
name: "PreToolUse",
expected: FieldType::StringArray,
expected: FieldType::HookArray,
},
FieldSpec {
name: "PostToolUse",
expected: FieldType::StringArray,
expected: FieldType::HookArray,
},
FieldSpec {
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) {
// Deprecated key — handled separately, not an unknown-key error.
} else {
// Unknown key.
let suggestion = suggest_field(key, &known_names);
result.errors.push(ConfigDiagnostic {
result.warnings.push(ConfigDiagnostic {
path: path_display.to_string(),
field: field_path,
line: find_key_line(source, key),
@@ -591,10 +605,11 @@ mod tests {
let result = validate_config_file(object, source, &test_path());
// then
assert_eq!(result.errors.len(), 1);
assert_eq!(result.errors[0].field, "unknownField");
assert!(result.errors.is_empty());
assert_eq!(result.warnings.len(), 1);
assert_eq!(result.warnings[0].field, "unknownField");
assert!(matches!(
result.errors[0].kind,
result.warnings[0].kind,
DiagnosticKind::UnknownKey { .. }
));
}
@@ -674,9 +689,10 @@ mod tests {
let result = validate_config_file(object, source, &test_path());
// then
assert_eq!(result.errors.len(), 1);
assert_eq!(result.errors[0].line, Some(3));
assert_eq!(result.errors[0].field, "badKey");
assert!(result.errors.is_empty());
assert_eq!(result.warnings.len(), 1);
assert_eq!(result.warnings[0].line, Some(3));
assert_eq!(result.warnings[0].field, "badKey");
}
#[test]
@@ -697,7 +713,7 @@ mod tests {
#[test]
fn validates_nested_hooks_keys() {
// 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 object = parsed.as_object().expect("object");
@@ -705,8 +721,64 @@ mod tests {
let result = validate_config_file(object, source, &test_path());
// 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[0].field, "hooks.BadHook");
assert_eq!(result.errors[0].field, "rulesImport");
}
#[test]
@@ -720,8 +792,9 @@ mod tests {
let result = validate_config_file(object, source, &test_path());
// then
assert_eq!(result.errors.len(), 1);
assert_eq!(result.errors[0].field, "permissions.denyAll");
assert!(result.errors.is_empty());
assert_eq!(result.warnings.len(), 1);
assert_eq!(result.warnings[0].field, "permissions.denyAll");
}
#[test]
@@ -735,8 +808,9 @@ mod tests {
let result = validate_config_file(object, source, &test_path());
// then
assert_eq!(result.errors.len(), 1);
assert_eq!(result.errors[0].field, "sandbox.containerMode");
assert!(result.errors.is_empty());
assert_eq!(result.warnings.len(), 1);
assert_eq!(result.warnings[0].field, "sandbox.containerMode");
}
#[test]
@@ -750,8 +824,9 @@ mod tests {
let result = validate_config_file(object, source, &test_path());
// then
assert_eq!(result.errors.len(), 1);
assert_eq!(result.errors[0].field, "plugins.autoUpdate");
assert!(result.errors.is_empty());
assert_eq!(result.warnings.len(), 1);
assert_eq!(result.warnings[0].field, "plugins.autoUpdate");
}
#[test]
@@ -765,8 +840,9 @@ mod tests {
let result = validate_config_file(object, source, &test_path());
// then
assert_eq!(result.errors.len(), 1);
assert_eq!(result.errors[0].field, "oauth.secret");
assert!(result.errors.is_empty());
assert_eq!(result.warnings.len(), 1);
assert_eq!(result.warnings[0].field, "oauth.secret");
}
#[test]
@@ -774,7 +850,7 @@ mod tests {
// given
let source = r#"{
"model": "opus",
"hooks": {"PreToolUse": ["guard"]},
"hooks": {"PreToolUse": [{"hooks":[{"type":"command","command":"guard"}]}]},
"permissions": {"defaultMode": "plan", "allow": ["Read"]},
"mcpServers": {},
"sandbox": {"enabled": false}
@@ -801,8 +877,9 @@ mod tests {
let result = validate_config_file(object, source, &test_path());
// then
assert_eq!(result.errors.len(), 1);
match &result.errors[0].kind {
assert!(result.errors.is_empty());
assert_eq!(result.warnings.len(), 1);
match &result.warnings[0].kind {
DiagnosticKind::UnknownKey {
suggestion: Some(s),
} => assert_eq!(s, "model"),
@@ -813,7 +890,7 @@ mod tests {
#[test]
fn format_diagnostics_includes_all_entries() {
// given
let source = r#"{"permissionMode": "plan", "badKey": 1}"#;
let source = r#"{"model": 42, "badKey": 1}"#;
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
let result = validate_config_file(object, source, &test_path());
@@ -825,7 +902,7 @@ mod tests {
assert!(output.contains("warning:"));
assert!(output.contains("error:"));
assert!(output.contains("badKey"));
assert!(output.contains("permissionMode"));
assert!(output.contains("model"));
}
#[test]