Skip to main content

xtask/codex_schema/
merge.rs

1//! Core schema-merging logic - a faithful Rust port of the definitions-
2//! combining half of the former `schema/build_combined_schema.py` (see git
3//! history for the original). Reused by both `regen` (the real merge) and
4//! `bisect` (building opaqued-out candidate schemas from the same merge).
5
6use std::collections::BTreeSet;
7
8use anyhow::{bail, Context, Result};
9use serde_json::{json, Map, Value};
10
11use super::naming;
12
13/// Recursively rewrites `"$ref": "#/definitions/v2/X"` to `"#/definitions/X"`
14/// so refs resolve against a single flat `definitions` namespace.
15pub fn rewrite_v2_refs(value: &Value) -> Value {
16    match value {
17        Value::Object(map) => {
18            let mut out = Map::with_capacity(map.len());
19            for (k, v) in map {
20                if k == "$ref" {
21                    if let Value::String(s) = v {
22                        if let Some(rest) = s.strip_prefix("#/definitions/v2/") {
23                            out.insert(k.clone(), Value::String(format!("#/definitions/{rest}")));
24                            continue;
25                        }
26                    }
27                }
28                out.insert(k.clone(), rewrite_v2_refs(v));
29            }
30            Value::Object(out)
31        }
32        Value::Array(items) => Value::Array(items.iter().map(rewrite_v2_refs).collect()),
33        other => other.clone(),
34    }
35}
36
37/// The `McpServerElicitationRequestParams` typify-0.7.0 workaround: merges a
38/// schema's top-level object `properties`/`required` into each sibling
39/// `oneOf` branch, producing a pure oneOf-of-self-contained-objects.
40/// Returns the schema unchanged unless it actually has this exact shape
41/// (top-level `oneOf` and `properties` both present).
42///
43/// Every `oneOf` branch must itself be an inline `{"type": "object", ...}`
44/// schema with a `properties` object (`required` may be absent, meaning no
45/// branch-specific required fields) - bails otherwise rather than silently
46/// treating an unrecognized branch shape (e.g. a bare `$ref` to a named
47/// branch type) as if it contributed no fields. Silently defaulting a branch
48/// to empty would let `typify` accept the resulting schema while silently
49/// dropping every field that branch's `$ref` would have contributed, with no
50/// build failure.
51pub fn flatten_base_plus_oneof(schema: &Value) -> Result<Value> {
52    let Some(obj) = schema.as_object() else {
53        return Ok(schema.clone());
54    };
55    if !(obj.contains_key("oneOf") && obj.contains_key("properties")) {
56        return Ok(schema.clone());
57    }
58
59    let base_props = obj
60        .get("properties")
61        .and_then(Value::as_object)
62        .cloned()
63        .unwrap_or_default();
64    let base_required: BTreeSet<String> = obj
65        .get("required")
66        .and_then(Value::as_array)
67        .map(|a| {
68            a.iter()
69                .filter_map(|v| v.as_str().map(String::from))
70                .collect()
71        })
72        .unwrap_or_default();
73    let one_of = obj
74        .get("oneOf")
75        .and_then(Value::as_array)
76        .cloned()
77        .unwrap_or_default();
78
79    let mut flattened = Vec::with_capacity(one_of.len());
80    for (i, branch) in one_of.iter().enumerate() {
81        let branch_obj = branch.as_object().with_context(|| {
82            format!(
83                "flatten_base_plus_oneof: oneOf branch {i} is not an inline object schema ({branch}) \
84                 - this workaround only knows how to flatten inline `{{\"type\": \"object\", ...}}` \
85                 branches. Update flatten_base_plus_oneof to handle this shape (e.g. a $ref branch)."
86            )
87        })?;
88        if branch_obj.get("type").and_then(Value::as_str) != Some("object") {
89            bail!(
90                "flatten_base_plus_oneof: oneOf branch {i} is not \"type\": \"object\" ({branch}) - \
91                 refusing to flatten a branch shape this workaround wasn't written for."
92            );
93        }
94        if !branch_obj.contains_key("properties") {
95            bail!(
96                "flatten_base_plus_oneof: oneOf branch {i} has no \"properties\" key ({branch}) - \
97                 refusing to flatten, since that would silently contribute zero fields from this \
98                 branch."
99            );
100        }
101
102        // {**base_props, **branch.properties}: base first (its order), then
103        // branch entries override/append - matches Python dict-merge.
104        let mut merged_props = base_props.clone();
105        if let Some(bp) = branch_obj.get("properties").and_then(Value::as_object) {
106            for (k, v) in bp {
107                merged_props.insert(k.clone(), v.clone());
108            }
109        }
110
111        // sorted(set(base_required) | set(branch_required))
112        let mut merged_required = base_required.clone();
113        if let Some(br) = branch_obj.get("required").and_then(Value::as_array) {
114            merged_required.extend(br.iter().filter_map(|v| v.as_str().map(String::from)));
115        }
116
117        let mut branch_out = Map::new();
118        branch_out.insert("type".to_string(), json!("object"));
119        branch_out.insert("properties".to_string(), Value::Object(merged_props));
120        branch_out.insert(
121            "required".to_string(),
122            Value::Array(merged_required.into_iter().map(Value::String).collect()),
123        );
124        flattened.push(Value::Object(branch_out));
125    }
126
127    let mut out = Map::new();
128    out.insert(
129        "title".to_string(),
130        obj.get("title").cloned().unwrap_or(Value::Null),
131    );
132    out.insert("oneOf".to_string(), Value::Array(flattened));
133    Ok(Value::Object(out))
134}
135
136/// Definition names known to legitimately differ between the master and v2
137/// bundles - v2's copy is the deliberately-pruned/authoritative one and is
138/// *expected* to differ from master's (ref-rewritten) copy. Any other
139/// name-collision with differing content is treated as a hard failure (see
140/// `check_collision_compatibility`) rather than silently letting v2 shadow a
141/// master definition that might carry different meaning.
142const EXPECTED_DIVERGENT_COLLISIONS: &[&str] =
143    &["ClientRequest", "ServerNotification", "RequestId"];
144
145/// For every definition name present in both `master_flat_rewritten` and
146/// `v2_defs`, verifies they're either identical or on the explicit
147/// known-divergent allowlist. `build_combined` always prefers v2's copy on a
148/// collision; this only exists to catch collisions where that's *not* safe -
149/// a future codex schema change that makes some other same-named type
150/// genuinely diverge between the two bundles would otherwise have v2 silently
151/// substitute the wrong shape into a master-derived envelope type (several of
152/// which transitively `$ref` into collision names like `AbsolutePathBuf` and
153/// `RequestId`), with no build failure and no warning.
154fn check_collision_compatibility(
155    master_flat_rewritten: &Map<String, Value>,
156    v2_defs: &Map<String, Value>,
157) -> Result<()> {
158    for (name, master_def) in master_flat_rewritten {
159        let Some(v2_def) = v2_defs.get(name) else {
160            continue;
161        };
162        if master_def == v2_def {
163            continue;
164        }
165        if EXPECTED_DIVERGENT_COLLISIONS.contains(&name.as_str()) {
166            continue;
167        }
168        bail!(
169            "build_combined: definition {name:?} exists in both the master and v2 schema bundles \
170             with DIFFERENT content, and is not in EXPECTED_DIVERGENT_COLLISIONS. v2 always wins \
171             collisions, so this would silently substitute v2's copy of {name:?} into any \
172             master-derived type that references it - which may be wrong if the two copies now mean \
173             different things. Either this is a legitimate new divergence (add {name:?} to \
174             EXPECTED_DIVERGENT_COLLISIONS with a comment explaining why), or something has gone \
175             wrong upstream in how codex generates these two bundles."
176        );
177    }
178    Ok(())
179}
180
181/// Merges the master bundle's flat top-level definitions (ref-rewritten)
182/// with the v2 bundle's definitions (v2 wins name collisions), applies the
183/// `McpServerElicitationRequestParams` flatten workaround, and wraps the
184/// result in the same schema envelope `build_combined_schema.py` produced.
185pub fn build_combined(master: &Value, v2: &Value) -> Result<Value> {
186    let master_defs = master
187        .get("definitions")
188        .and_then(Value::as_object)
189        .context("master bundle missing top-level \"definitions\" object")?;
190    let v2_defs = v2
191        .get("definitions")
192        .and_then(Value::as_object)
193        .context("v2 bundle missing top-level \"definitions\" object")?;
194
195    let mut master_flat_rewritten: Map<String, Value> = Map::new();
196    for (k, v) in master_defs {
197        if k == "v2" {
198            continue;
199        }
200        master_flat_rewritten.insert(k.clone(), rewrite_v2_refs(v));
201    }
202
203    check_collision_compatibility(&master_flat_rewritten, v2_defs)?;
204
205    let mut combined_defs = master_flat_rewritten;
206    for (k, v) in v2_defs {
207        // v2 wins name collisions - `Map::insert` on an existing key updates
208        // its value, matching Python's `{**master_flat_rewritten,
209        // **v2["definitions"]}` merge semantics. Iteration order of the
210        // result is NOT meaningful (see xtask/Cargo.toml's serde_json entry -
211        // `preserve_order` was removed after it leaked into unrelated
212        // generated docs), only which value wins per key.
213        combined_defs.insert(k.clone(), v.clone());
214    }
215
216    if let Some(schema) = combined_defs
217        .get("McpServerElicitationRequestParams")
218        .cloned()
219    {
220        combined_defs.insert(
221            "McpServerElicitationRequestParams".to_string(),
222            flatten_base_plus_oneof(&schema)?,
223        );
224    }
225
226    Ok(wrap_definitions(combined_defs))
227}
228
229/// Wraps a flat `definitions` map in the schema envelope the crate's
230/// `build.rs` expects (`$schema`/`title`/`description`/`type`/`definitions`,
231/// in that field order).
232pub fn wrap_definitions(definitions: Map<String, Value>) -> Value {
233    let mut out = Map::new();
234    out.insert(
235        "$schema".to_string(),
236        json!("http://json-schema.org/draft-07/schema#"),
237    );
238    out.insert("title".to_string(), json!("CodexAppServerProtocolCombined"));
239    out.insert(
240        "description".to_string(),
241        json!(
242            "Merged, flat-ref, self-contained v2-only Codex app-server protocol schema \
243             (master envelope/ServerRequest/ClientNotification types ref-rewritten to flat \
244             + v2 client-request/notification surface). Generated by \
245             `cargo xtask codex-schema regen`."
246        ),
247    );
248    out.insert("type".to_string(), json!("object"));
249    out.insert("definitions".to_string(), Value::Object(definitions));
250    Value::Object(out)
251}
252
253/// Extracts the `method` string of every `oneOf` branch of a discriminated
254/// union definition (e.g. `ClientRequest`), in schema order.
255pub fn methods_of(union_def: &Value) -> Result<Vec<String>> {
256    let one_of = union_def
257        .get("oneOf")
258        .and_then(Value::as_array)
259        .context("union definition missing \"oneOf\" array")?;
260    one_of
261        .iter()
262        .map(|entry| {
263            entry
264                .pointer("/properties/method/enum/0")
265                .and_then(Value::as_str)
266                .map(str::to_string)
267                .context("oneOf entry missing properties.method.enum[0]")
268        })
269        .collect()
270}
271
272/// The resolved params type for one method's `oneOf` branch: the referenced
273/// type name (if any) and whether the params were nullable (2-way anyOf).
274#[derive(Debug)]
275pub struct ParamsType {
276    pub type_name: Option<String>,
277    pub optional: bool,
278}
279
280/// STRICT: only a plain `$ref`, a 2-way nullable-ref `anyOf`, an explicit
281/// `{"type": "null"}`, or a missing `"params"` key are recognized. Any other
282/// shape is a hard failure, matching the Python original's
283/// `params_type_for` docstring: raising here (rather than silently falling
284/// back to "no params") is load-bearing - the wrapper codegen in `build.rs`
285/// would otherwise silently emit `params: ()` for a method that actually
286/// requires typed params.
287pub fn params_type_for(method: &str, union_def: &Value) -> Result<ParamsType> {
288    let one_of = union_def
289        .get("oneOf")
290        .and_then(Value::as_array)
291        .context("union definition missing \"oneOf\" array")?;
292
293    for entry in one_of {
294        let entry_method = entry
295            .pointer("/properties/method/enum/0")
296            .and_then(Value::as_str);
297        if entry_method != Some(method) {
298            continue;
299        }
300
301        let Some(params_schema) = entry.pointer("/properties/params") else {
302            return Ok(ParamsType {
303                type_name: None,
304                optional: false,
305            });
306        };
307        if params_schema.get("type").and_then(Value::as_str) == Some("null") {
308            return Ok(ParamsType {
309                type_name: None,
310                optional: false,
311            });
312        }
313        if let Some(r) = params_schema.get("$ref").and_then(Value::as_str) {
314            return Ok(ParamsType {
315                type_name: Some(ref_name(r)),
316                optional: false,
317            });
318        }
319        if let Some(any_of) = params_schema.get("anyOf").and_then(Value::as_array) {
320            if any_of.len() == 2 {
321                let refs: Vec<&Value> = any_of.iter().filter(|b| b.get("$ref").is_some()).collect();
322                let nulls: Vec<&Value> = any_of
323                    .iter()
324                    .filter(|b| b.get("type").and_then(Value::as_str) == Some("null"))
325                    .collect();
326                if refs.len() == 1 && nulls.len() == 1 {
327                    let r = refs[0].get("$ref").and_then(Value::as_str).unwrap();
328                    return Ok(ParamsType {
329                        type_name: Some(ref_name(r)),
330                        optional: true,
331                    });
332                }
333            }
334        }
335        bail!(
336            "{method}: unrecognized 'params' schema shape {params_schema} - not a plain $ref, a \
337             nullable $ref, or an explicit null. Update params_type_for to handle this shape (or \
338             the wrapper codegen in build.rs will silently emit `params: ()` for a method that \
339             actually requires typed params)."
340        );
341    }
342    bail!("{method}: not found in the given union's oneOf branches");
343}
344
345fn ref_name(r: &str) -> String {
346    r.rsplit('/').next().unwrap_or(r).to_string()
347}
348
349/// One `client_requests`/`server_requests` manifest entry (includes a
350/// resolved response type). Field order is JSON serialization order - must
351/// stay `method, variant_name, fn_name, params_type, params_optional,
352/// response_type` to byte-match the Python original's output.
353#[derive(serde::Serialize)]
354pub struct RequestEntry {
355    pub method: String,
356    pub variant_name: String,
357    pub fn_name: String,
358    pub params_type: Option<String>,
359    pub params_optional: bool,
360    pub response_type: Option<String>,
361}
362
363/// One `server_notifications`/`client_notifications` manifest entry - no
364/// `response_type` field at all (not even `null`), matching the Python
365/// original's dict literal for notifications.
366#[derive(serde::Serialize)]
367pub struct NotificationEntry {
368    pub method: String,
369    pub variant_name: String,
370    pub fn_name: String,
371    pub params_type: Option<String>,
372    pub params_optional: bool,
373}
374
375#[derive(serde::Serialize)]
376pub struct MethodsManifest {
377    pub client_requests: Vec<RequestEntry>,
378    pub server_requests: Vec<RequestEntry>,
379    pub server_notifications: Vec<NotificationEntry>,
380    pub client_notifications: Vec<NotificationEntry>,
381}
382
383/// Builds the full `methods.json` manifest from a merged, flat `definitions`
384/// map (as produced by `build_combined`).
385pub fn build_methods_manifest(combined_defs: &Map<String, Value>) -> Result<MethodsManifest> {
386    let client_request_union = get_def(combined_defs, "ClientRequest")?;
387    let server_request_union = get_def(combined_defs, "ServerRequest")?;
388    let server_notification_union = get_def(combined_defs, "ServerNotification")?;
389    let client_notification_union = get_def(combined_defs, "ClientNotification")?;
390
391    let mut client_requests = Vec::new();
392    for m in methods_of(client_request_union)? {
393        let p = params_type_for(&m, client_request_union)?;
394        let response_type = naming::resolve_response(&m, combined_defs)?;
395        client_requests.push(RequestEntry {
396            variant_name: naming::method_to_pascal(&m),
397            fn_name: naming::method_to_snake_fn(&m),
398            params_type: p.type_name,
399            params_optional: p.optional,
400            response_type,
401            method: m,
402        });
403    }
404
405    let mut server_requests = Vec::new();
406    for m in methods_of(server_request_union)? {
407        let p = params_type_for(&m, server_request_union)?;
408        let response_type = naming::resolve_response(&m, combined_defs)?;
409        server_requests.push(RequestEntry {
410            variant_name: naming::method_to_pascal(&m),
411            fn_name: naming::method_to_snake_fn(&m),
412            params_type: p.type_name,
413            params_optional: p.optional,
414            response_type,
415            method: m,
416        });
417    }
418
419    let mut server_notifications = Vec::new();
420    for m in methods_of(server_notification_union)? {
421        let p = params_type_for(&m, server_notification_union)?;
422        server_notifications.push(NotificationEntry {
423            variant_name: naming::method_to_pascal(&m),
424            fn_name: naming::method_to_snake_fn(&m),
425            params_type: p.type_name,
426            params_optional: p.optional,
427            method: m,
428        });
429    }
430
431    let mut client_notifications = Vec::new();
432    for m in methods_of(client_notification_union)? {
433        let p = params_type_for(&m, client_notification_union)?;
434        client_notifications.push(NotificationEntry {
435            variant_name: naming::method_to_pascal(&m),
436            fn_name: naming::method_to_snake_fn(&m),
437            params_type: p.type_name,
438            params_optional: p.optional,
439            method: m,
440        });
441    }
442
443    Ok(MethodsManifest {
444        client_requests,
445        server_requests,
446        server_notifications,
447        client_notifications,
448    })
449}
450
451fn get_def<'a>(defs: &'a Map<String, Value>, name: &str) -> Result<&'a Value> {
452    defs.get(name)
453        .with_context(|| format!("combined definitions missing required union type {name:?}"))
454}
455
456#[cfg(test)]
457#[path = "merge_tests.rs"]
458mod tests;