Skip to main content

xtask/codex_schema/
bisect.rs

1//! `cargo xtask codex-schema bisect <dir>` - binary-searches a fresh schema
2//! dump for the minimal definition(s) that panic typify's schema-merge
3//! logic (typify-impl-0.7.0's `merge.rs:427`, "not yet implemented" - the
4//! same failure mode `McpServerElicitationRequestParams` hit; see
5//! `crates/shared/codex-app-server-client/README.md`). Automates the "opaque out
6//! half the new/changed definitions, see if the panic goes away" process
7//! that was previously done by hand.
8
9use std::collections::BTreeSet;
10use std::path::{Path, PathBuf};
11
12use anyhow::Result;
13use serde_json::{Map, Value};
14
15use super::typify_probe::{self, ProbeOutcome};
16use super::{load_combined_defs, merge, parse_gen_dir, PROTOCOL_SCHEMA_PATH};
17
18pub fn run(args: &[String]) -> Result<()> {
19    let gen_dir = parse_args(args)?;
20    bisect(&gen_dir)
21}
22
23fn parse_args(args: &[String]) -> Result<PathBuf> {
24    parse_gen_dir(
25        args,
26        "Usage: cargo xtask codex-schema bisect <path-to-codex-generate-json-schema-output-dir>",
27    )
28}
29
30pub fn bisect(gen_dir: &Path) -> Result<()> {
31    let (combined, defs) = load_combined_defs(gen_dir)?;
32
33    println!(
34        "==> probing the full merged schema ({} definitions)...",
35        defs.len()
36    );
37    let full_outcome = typify_probe::probe(&combined);
38    println!("    {}", full_outcome.summary());
39
40    if !full_outcome.reproduces_target() {
41        println!(
42            "\n==> the full merged schema does not panic with the target typify merge.rs shape."
43        );
44        match full_outcome {
45            ProbeOutcome::Success => {
46                println!("    typify converted it successfully - nothing to bisect.")
47            }
48            other => println!(
49                "    outcome was instead: {}\n    this tool only bisects the specific merge.rs \
50                 \"not yet implemented\" panic - investigate this outcome by hand.",
51                other.summary()
52            ),
53        }
54        return Ok(());
55    }
56
57    let universe = suspect_universe(&defs);
58    let scope_note = if universe.len() == defs.len() {
59        " (no usable baseline diff - searching every definition)".to_string()
60    } else {
61        format!(" (definitions new or changed vs. the committed {PROTOCOL_SCHEMA_PATH})")
62    };
63    println!(
64        "\n==> search universe: {} suspect definition(s){scope_note}",
65        universe.len()
66    );
67
68    let culprits = minimize(&defs, universe);
69
70    println!("\n==> bisection complete. culprit definition(s):");
71    for name in &culprits {
72        println!("\n--- {name} ---");
73        match defs.get(name) {
74            Some(schema) => println!(
75                "{}",
76                serde_json::to_string_pretty(schema).unwrap_or_else(|_| "<unserializable>".into())
77            ),
78            None => println!("<definition not found - internal bisection bug>"),
79        }
80    }
81    println!(
82        "\nNext step: either flatten the offending shape the way \
83         McpServerElicitationRequestParams was handled (see \
84         `xtask/src/codex_schema/merge.rs::flatten_base_plus_oneof` and the crate README's \"How \
85         the typed protocol layer is built\" section), or, as a last resort, opaque this \
86         definition to `true` (serde_json::Value) in `build_combined` and document the loss of \
87         type fidelity."
88    );
89    Ok(())
90}
91
92/// Definitions that are new or textually changed vs. the currently
93/// committed `protocol.schema.json`. Falls back to searching every
94/// definition when no usable baseline is found (e.g. the baseline file is
95/// missing/unparseable, or nothing actually changed).
96fn suspect_universe(defs: &Map<String, Value>) -> BTreeSet<String> {
97    let baseline_defs = std::fs::read_to_string(PROTOCOL_SCHEMA_PATH)
98        .ok()
99        .and_then(|text| serde_json::from_str::<Value>(&text).ok())
100        .and_then(|v| v.get("definitions").and_then(Value::as_object).cloned());
101
102    let Some(baseline_defs) = baseline_defs else {
103        return defs.keys().cloned().collect();
104    };
105
106    let changed: BTreeSet<String> = defs
107        .iter()
108        .filter(|(name, schema)| baseline_defs.get(*name) != Some(schema))
109        .map(|(name, _)| name.clone())
110        .collect();
111
112    if changed.is_empty() {
113        defs.keys().cloned().collect()
114    } else {
115        changed
116    }
117}
118
119/// Binary-searches `universe` (a set of definition names that, kept real
120/// with everything else in `all_defs` at its natural realness, is already
121/// known to reproduce the target panic) down to a minimal culprit set.
122fn minimize(all_defs: &Map<String, Value>, mut universe: BTreeSet<String>) -> Vec<String> {
123    loop {
124        if universe.len() <= 1 {
125            return universe.into_iter().collect();
126        }
127
128        let mid = universe.len() / 2;
129        let (first, second) = split_at(&universe, mid);
130
131        println!(
132            "==> {} candidate(s) remaining - trying first half alone ({} defs)...",
133            universe.len(),
134            first.len()
135        );
136        if probe_with_kept(all_defs, &universe, &first).reproduces_target() {
137            universe = first;
138            continue;
139        }
140
141        println!(
142            "    not reproduced; trying second half alone ({} defs)...",
143            second.len()
144        );
145        if probe_with_kept(all_defs, &universe, &second).reproduces_target() {
146            universe = second;
147            continue;
148        }
149
150        println!(
151            "    neither half alone reproduces the panic - these {} definition(s) appear to \
152             jointly trigger it. Reporting the full remaining set.",
153            universe.len()
154        );
155        return universe.into_iter().collect();
156    }
157}
158
159fn split_at(set: &BTreeSet<String>, mid: usize) -> (BTreeSet<String>, BTreeSet<String>) {
160    let mut first = BTreeSet::new();
161    let mut second = BTreeSet::new();
162    for (i, item) in set.iter().enumerate() {
163        if i < mid {
164            first.insert(item.clone());
165        } else {
166            second.insert(item.clone());
167        }
168    }
169    (first, second)
170}
171
172/// Builds a candidate schema where every definition in `universe` but not
173/// in `keep_real` is opaqued to `true`; definitions outside `universe` are
174/// left exactly as they are in `all_defs`. Probes it with typify.
175fn probe_with_kept(
176    all_defs: &Map<String, Value>,
177    universe: &BTreeSet<String>,
178    keep_real: &BTreeSet<String>,
179) -> ProbeOutcome {
180    let mut candidate_defs = all_defs.clone();
181    for name in universe {
182        if !keep_real.contains(name) {
183            candidate_defs.insert(name.clone(), Value::Bool(true));
184        }
185    }
186    typify_probe::probe(&merge::wrap_definitions(candidate_defs))
187}
188
189#[cfg(test)]
190#[path = "bisect_tests.rs"]
191mod tests;