fix(schema): keep required groups of composition branches (Audit finding D04-11, partial)

prune_dangling_required removed every required entry absent from local
properties, including composition branches like apply_patch's
oneOf: [{required: [patch]}, ...] whose names live on the parent. The
generic sanitizer turned that into oneOf: [{}, {}, {}], which no input
can satisfy, and the finance tool's anyOf ticker/symbol into no
constraint. A schema with neither properties nor type is now left alone.

Remaining (documented in place): a type: object schema with no
properties still loses required entries, because DeepSeek strict and
MFJS reject required names outside local properties and this
provider-neutral pass cannot invent their types.

Evidence (keeps_required_groups_of_composition_branches):
- production reverted: test result: FAILED. 1 passed; 10 failed; 0 ignored; 0 measured; 14431 filtered out; finished in 5.15s (all lane proof tests; the 1 pass is the finance overflow-literal disproval test)
  failure: left: [{}, {}, {}] right: [{required:[patch]}, ...]
- restored (cmp identical): test result: ok. 374 passed; 0 failed; 0 ignored; 0 measured; 14068 filtered out; finished in 2.95s (lane filter set, all new tests included)
The Kimi/Moonshot and agent schema tests in the filter set pass.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: CodeWhale Bot <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-09-30 12:39:25 -07:00
1 parent 51bf574e47
commit 46835a2fcd
1 file changed
+51
+51
View File
@@ -213,10 +213,26 @@ fn inject_properties_on_bare_objects(schema: &mut Value) {
}
/// Remove entries from `required` that aren't keys in `properties`.
///
/// A schema with neither `properties` nor `type` is a composition branch
/// whose `required` names properties declared by the enclosing schema —
/// `oneOf: [{"required": ["patch"]}, ...]` (#6561 D04-11). Its list is kept:
/// pruning emptied every branch, turning apply_patch's "exactly one of"
/// into an unsatisfiable `oneOf` of three `{}` and the finance tool's
/// "ticker or symbol" into no constraint.
///
/// Known limitation: a `type: "object"` schema without `properties` still
/// loses `required` entries (a bare object gets `properties: {}` injected
/// first). Strict validators (DeepSeek strict, MFJS) reject `required`
/// names absent from local `properties`, and this provider-neutral pass
/// cannot invent their property types.
fn prune_dangling_required(schema: &mut Value) {
let Some(obj) = schema.as_object_mut() else {
return;
};
if !obj.contains_key("properties") && !obj.contains_key("type") {
return;
}
// Collect known property names first (immutable borrow), then prune.
let known_keys: Vec<String> = obj
.get("properties")
@@ -557,6 +573,41 @@ mod tests {
assert!(schema.get("required").is_none());
}
/// #6561 D04-11: composition branches name the parent's properties.
#[test]
fn keeps_required_groups_of_composition_branches() {
let mut schema = json!({
"type": "object",
"properties": {
"patch": {"type": "string"},
"replace": {"type": "array"},
"changes": {"type": "array"}
},
"oneOf": [
{"required": ["patch"]},
{"required": ["replace"]},
{"required": ["changes"]}
]
});
sanitize(&mut schema);
assert_eq!(
schema["oneOf"],
json!([
{"required": ["patch"]},
{"required": ["replace"]},
{"required": ["changes"]}
])
);
// A typed object still has its dangling names pruned.
let mut typed = json!({
"type": "object",
"properties": {"name": {"type": "string"}},
"required": ["name", "ghost"]
});
sanitize(&mut typed);
assert_eq!(typed["required"], json!(["name"]));
}
#[test]
fn collapses_single_element_oneof() {
let mut schema = json!({