Skip to main content

xtask/codex_schema/
drift.rs

1//! `cargo xtask codex-schema drift [--dir <dump-dir>] [--json] [--strict]` -
2//! diffs the vendored `schema/methods.json` + `schema/CODEX_VERSION.txt`
3//! against a fresh `codex app-server generate-json-schema` dump (or an
4//! existing dump directory passed via `--dir`), so an upstream `codex`
5//! version bump that adds, removes, or renames a method can't silently slip
6//! past the version-string-only staleness check in
7//! `crates/shared/codex-app-server-client/build.rs`.
8//!
9//! Reuses `regen`'s exact schema-load + manifest-build code path
10//! (`load_combined_defs` + `merge::build_methods_manifest`) to build the
11//! "installed" manifest in memory - this module never reimplements schema
12//! parsing or method extraction, it only diffs two already-built manifests.
13//! See `crates/shared/codex-app-server-client/README.md`'s "Regenerating the
14//! schema" section, `build.rs`'s non-fatal staleness warning, and
15//! `codex_app_server_client::compat::CompatibilityReport` (schema-version +
16//! method-count only) for the prior art this check sharpens into a real diff.
17
18use std::collections::BTreeMap;
19use std::fs;
20use std::path::{Path, PathBuf};
21use std::process::Command;
22
23use anyhow::{bail, Context, Result};
24use serde::Serialize;
25use serde_json::Value;
26
27use super::{load_combined_defs, merge, CODEX_VERSION_PATH, METHODS_JSON_PATH};
28
29const USAGE: &str = "Usage: cargo xtask codex-schema drift [--dir <path-to-codex-generate-json-schema-output-dir>] [--json] [--strict]
30
31Diffs the vendored schema/methods.json + schema/CODEX_VERSION.txt against a
32fresh `codex app-server generate-json-schema --out <dir> --experimental` dump
33(or an existing dump directory passed via --dir) and reports added, removed,
34and changed methods per section (client_requests, server_requests,
35server_notifications, client_notifications).
36
37  --dir <dir>  Diff against an already-generated dump directory instead of
38               shelling out to `codex` to produce a fresh one. Makes the
39               check testable/reusable in CI without requiring `codex` on
40               PATH for the dump step itself.
41  --json       Emit a machine-readable report instead of the human-readable
42               one.
43  --strict     Exit non-zero when drift is found. Without --strict, drift is
44               still reported loudly but the command exits 0.
45
46Missing `codex` on PATH (when --dir is not given) is never a hard failure:
47the check prints \"skipped\" and exits 0, mirroring build.rs's staleness
48check and tests/smoke.rs's live-integration test.";
49
50/// Section names in the vendored/installed `methods.json` shape, in the
51/// fixed order the report is always rendered in.
52const SECTIONS: &[&str] = &[
53    "client_requests",
54    "server_requests",
55    "server_notifications",
56    "client_notifications",
57];
58
59pub fn run(args: &[String]) -> Result<()> {
60    let options = parse_args(args)?;
61    match compute_outcome(&options)? {
62        Outcome::Skipped(reason) => {
63            if options.json {
64                println!("{}", serde_json::json!({"skipped": true, "reason": reason}));
65            } else {
66                println!("codex-schema drift: skipped: {reason}");
67            }
68            Ok(())
69        }
70        Outcome::Report(report) => {
71            if options.json {
72                println!(
73                    "{}",
74                    serde_json::to_string_pretty(&report)
75                        .context("serialize drift report as JSON")?
76                );
77            } else {
78                print_human_report(&report);
79            }
80            if !report.in_sync && options.strict {
81                bail!(
82                    "codex-schema drift: {} method(s) drifted from the vendored schema (--strict) \
83                     - review the report above, then run `cargo xtask codex-schema regen <dir>` \
84                     once the change is intentional.",
85                    report.drifted_count
86                );
87            }
88            Ok(())
89        }
90    }
91}
92
93#[derive(Debug)]
94struct DriftOptions {
95    dir: Option<PathBuf>,
96    json: bool,
97    strict: bool,
98}
99
100fn parse_args(args: &[String]) -> Result<DriftOptions> {
101    let mut dir = None;
102    let mut json = false;
103    let mut strict = false;
104    let mut index = 0usize;
105    while index < args.len() {
106        match args[index].as_str() {
107            "--dir" => {
108                index += 1;
109                let value = args
110                    .get(index)
111                    .with_context(|| format!("--dir requires a value\n\n{USAGE}"))?;
112                dir = Some(PathBuf::from(value));
113            }
114            "--json" => json = true,
115            "--strict" => strict = true,
116            "--help" | "-h" => {
117                println!("{USAGE}");
118                std::process::exit(0);
119            }
120            other => bail!("unexpected argument: {other}\n\n{USAGE}"),
121        }
122        index += 1;
123    }
124    Ok(DriftOptions { dir, json, strict })
125}
126
127enum Outcome {
128    Skipped(String),
129    Report(DriftReport),
130}
131
132/// Builds the diff outcome: either a skip reason (no `codex` on PATH and no
133/// `--dir`) or a full `DriftReport`. Keeps the temp dump directory (when one
134/// is created) alive for the duration of the manifest build via the returned
135/// `TempDir` guard's drop timing.
136fn compute_outcome(options: &DriftOptions) -> Result<Outcome> {
137    let vendored_version = load_vendored_version()?;
138    let vendored_manifest = load_vendored_manifest()?;
139
140    let (gen_dir, installed_version, _dump_guard) = match &options.dir {
141        Some(dir) => {
142            if !dir.exists() {
143                bail!(
144                    "--dir {} does not exist - generate it first with:\n  \
145                     codex app-server generate-json-schema --out {} --experimental",
146                    dir.display(),
147                    dir.display()
148                );
149            }
150            (dir.clone(), installed_version_best_effort(), None)
151        }
152        None => {
153            let Some(installed_version) = codex_version_or_skip()? else {
154                return Ok(Outcome::Skipped(
155                    "no codex on PATH - cannot generate a fresh schema dump to diff against. \
156                     Install the codex CLI, or pass --dir <existing-dump> to diff a saved \
157                     `codex app-server generate-json-schema` output."
158                        .to_string(),
159                ));
160            };
161            let dump =
162                tempfile::tempdir().context("create temp dir for a fresh codex schema dump")?;
163            generate_fresh_dump(dump.path())?;
164            let gen_dir = dump.path().to_path_buf();
165            (gen_dir, Some(installed_version), Some(dump))
166        }
167    };
168
169    let installed_manifest = build_installed_manifest(&gen_dir)?;
170    let report = build_report(
171        &vendored_manifest,
172        &installed_manifest,
173        &vendored_version,
174        installed_version.as_deref(),
175    )?;
176    Ok(Outcome::Report(report))
177}
178
179/// Runs `codex --version` and distinguishes "codex is not on PATH at all"
180/// (the one condition this check must never hard-fail on) from every other
181/// failure mode (codex present but broken), which are real errors.
182fn codex_version_or_skip() -> Result<Option<String>> {
183    match Command::new("codex").arg("--version").output() {
184        Ok(output) if output.status.success() => {
185            let raw = String::from_utf8(output.stdout)
186                .context("`codex --version` emitted non-UTF-8 output")?;
187            let trimmed = raw.trim();
188            if trimmed.is_empty() {
189                bail!("`codex --version` produced no output");
190            }
191            Ok(Some(trimmed.to_string()))
192        }
193        Ok(output) => bail!(
194            "`codex --version` exited with status {}: {}",
195            output.status,
196            String::from_utf8_lossy(&output.stderr)
197        ),
198        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
199        Err(err) => Err(err).context("failed to run `codex --version`"),
200    }
201}
202
203/// Best-effort installed-version lookup for `--dir` mode: `codex` is purely
204/// informational there (the dump already exists), so any failure is silently
205/// treated as "unknown" rather than blocking the diff - mirrors
206/// `build.rs`'s `check_codex_staleness`.
207fn installed_version_best_effort() -> Option<String> {
208    let output = Command::new("codex").arg("--version").output().ok()?;
209    if !output.status.success() {
210        return None;
211    }
212    let raw = String::from_utf8(output.stdout).ok()?;
213    let trimmed = raw.trim();
214    (!trimmed.is_empty()).then(|| trimmed.to_string())
215}
216
217/// Shells out to the exact command documented in this crate's `regen`
218/// workflow (`codex_schema.rs`'s `print_help`) to produce a fresh dump.
219fn generate_fresh_dump(out_dir: &Path) -> Result<()> {
220    let status = Command::new("codex")
221        .arg("app-server")
222        .arg("generate-json-schema")
223        .arg("--out")
224        .arg(out_dir)
225        .arg("--experimental")
226        .status()
227        .context("failed to run `codex app-server generate-json-schema`")?;
228    if !status.success() {
229        bail!(
230            "`codex app-server generate-json-schema --out {} --experimental` exited with status {status}",
231            out_dir.display()
232        );
233    }
234    Ok(())
235}
236
237fn load_vendored_manifest() -> Result<Value> {
238    super::read_json(Path::new(METHODS_JSON_PATH))
239}
240
241fn load_vendored_version() -> Result<String> {
242    let text = fs::read_to_string(CODEX_VERSION_PATH)
243        .with_context(|| format!("read {CODEX_VERSION_PATH}"))?;
244    let trimmed = text.trim();
245    if trimmed.is_empty() {
246        bail!("{CODEX_VERSION_PATH} is empty");
247    }
248    Ok(trimmed.to_string())
249}
250
251/// Builds the "installed" manifest via the exact same code path `regen` uses
252/// to write `methods.json` (`load_combined_defs` + `build_methods_manifest`),
253/// then converts it to a `Value` so it can be diffed with the same
254/// section-shaped logic as the vendored JSON file - no separate parsing path
255/// to drift out of sync with the real one.
256fn build_installed_manifest(gen_dir: &Path) -> Result<Value> {
257    let (_, combined_defs) = load_combined_defs(gen_dir)?;
258    let manifest = merge::build_methods_manifest(&combined_defs)?;
259    serde_json::to_value(&manifest)
260        .context("serialize installed methods manifest to JSON for diffing")
261}
262
263/// One method's diffable signature: everything about it other than its name
264/// that `regen` derives from the schema. Notification entries never carry a
265/// `response_type` key in the manifest JSON, so `response_type` is `None` for
266/// every notification-section entry - correctly comparing "no key" in both
267/// vendored and installed as equal, rather than a spurious "changed".
268#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
269struct MethodSignature {
270    params_type: Option<String>,
271    params_optional: bool,
272    response_type: Option<String>,
273}
274
275/// Parses one manifest's section array (`client_requests`, etc.) into a
276/// `method -> signature` map. Shared by both the vendored JSON file and the
277/// installed manifest (also serialized to `Value`), so both sides go through
278/// identical extraction logic - a field name typo here would otherwise show
279/// up as one specific, hard-to-diagnose false "changed" or "added"/"removed"
280/// pair instead of a loud, obvious error.
281fn section_map(manifest: &Value, section: &str) -> Result<BTreeMap<String, MethodSignature>> {
282    let entries = manifest
283        .get(section)
284        .and_then(Value::as_array)
285        .with_context(|| format!("manifest missing \"{section}\" array"))?;
286
287    let mut map = BTreeMap::new();
288    for (index, entry) in entries.iter().enumerate() {
289        let method = entry
290            .get("method")
291            .and_then(Value::as_str)
292            .with_context(|| format!("{section}[{index}] is missing a string \"method\" field"))?
293            .to_string();
294        let signature = MethodSignature {
295            params_type: entry
296                .get("params_type")
297                .and_then(Value::as_str)
298                .map(str::to_string),
299            params_optional: entry
300                .get("params_optional")
301                .and_then(Value::as_bool)
302                .unwrap_or(false),
303            response_type: entry
304                .get("response_type")
305                .and_then(Value::as_str)
306                .map(str::to_string),
307        };
308        if map.insert(method.clone(), signature).is_some() {
309            bail!("{section}: duplicate method {method:?} in manifest - malformed input");
310        }
311    }
312    Ok(map)
313}
314
315#[derive(Debug, Clone, Serialize)]
316struct MethodEntry {
317    method: String,
318    params_type: Option<String>,
319    params_optional: bool,
320    response_type: Option<String>,
321}
322
323impl MethodEntry {
324    fn new(method: &str, signature: &MethodSignature) -> Self {
325        Self {
326            method: method.to_string(),
327            params_type: signature.params_type.clone(),
328            params_optional: signature.params_optional,
329            response_type: signature.response_type.clone(),
330        }
331    }
332}
333
334#[derive(Debug, Clone, Serialize)]
335struct ChangedMethod {
336    method: String,
337    vendored: MethodSignature,
338    installed: MethodSignature,
339}
340
341#[derive(Debug, Clone, Serialize)]
342struct SectionDiff {
343    section: String,
344    vendored_count: usize,
345    installed_count: usize,
346    added: Vec<MethodEntry>,
347    removed: Vec<MethodEntry>,
348    changed: Vec<ChangedMethod>,
349}
350
351impl SectionDiff {
352    fn drifted_count(&self) -> usize {
353        self.added.len() + self.removed.len() + self.changed.len()
354    }
355
356    fn in_sync(&self) -> bool {
357        self.drifted_count() == 0
358    }
359}
360
361/// Diffs one section (`client_requests`, `server_requests`,
362/// `server_notifications`, or `client_notifications`) between the vendored
363/// and installed manifests. `added`/`removed`/`changed` are each sorted by
364/// method name for stable, scannable output regardless of the underlying
365/// `BTreeMap` iteration order (which is already method-name order, but the
366/// explicit sort documents the intent and survives a future change to how
367/// the maps are built).
368fn diff_section(section: &str, vendored: &Value, installed: &Value) -> Result<SectionDiff> {
369    let vendored_map = section_map(vendored, section)?;
370    let installed_map = section_map(installed, section)?;
371
372    let mut added = Vec::new();
373    let mut changed = Vec::new();
374    for (method, installed_sig) in &installed_map {
375        match vendored_map.get(method) {
376            None => added.push(MethodEntry::new(method, installed_sig)),
377            Some(vendored_sig) if vendored_sig != installed_sig => changed.push(ChangedMethod {
378                method: method.clone(),
379                vendored: vendored_sig.clone(),
380                installed: installed_sig.clone(),
381            }),
382            Some(_) => {}
383        }
384    }
385
386    let mut removed = Vec::new();
387    for (method, vendored_sig) in &vendored_map {
388        if !installed_map.contains_key(method) {
389            removed.push(MethodEntry::new(method, vendored_sig));
390        }
391    }
392
393    added.sort_by(|a, b| a.method.cmp(&b.method));
394    removed.sort_by(|a, b| a.method.cmp(&b.method));
395    changed.sort_by(|a, b| a.method.cmp(&b.method));
396
397    Ok(SectionDiff {
398        section: section.to_string(),
399        vendored_count: vendored_map.len(),
400        installed_count: installed_map.len(),
401        added,
402        removed,
403        changed,
404    })
405}
406
407#[derive(Debug, Clone, Serialize)]
408struct VersionDelta {
409    vendored: String,
410    installed: Option<String>,
411    /// `None` when the installed version could not be determined (e.g.
412    /// `--dir` was used and `codex` isn't on PATH either); `Some(false)`
413    /// when it's known and differs from the vendored stamp.
414    matches: Option<bool>,
415}
416
417#[derive(Debug, Clone, Serialize)]
418struct DriftReport {
419    /// True only when every section has zero added/removed/changed methods.
420    /// A version-string mismatch with an otherwise identical method surface
421    /// does NOT by itself count as drift here - `build.rs`'s staleness
422    /// warning already covers that case; this report's job is to make method
423    /// *surface* changes impossible to miss.
424    in_sync: bool,
425    drifted_count: usize,
426    version: VersionDelta,
427    sections: Vec<SectionDiff>,
428}
429
430fn build_report(
431    vendored_manifest: &Value,
432    installed_manifest: &Value,
433    vendored_version: &str,
434    installed_version: Option<&str>,
435) -> Result<DriftReport> {
436    let sections = SECTIONS
437        .iter()
438        .map(|section| diff_section(section, vendored_manifest, installed_manifest))
439        .collect::<Result<Vec<_>>>()?;
440    let drifted_count = sections.iter().map(SectionDiff::drifted_count).sum();
441    let version = VersionDelta {
442        vendored: vendored_version.to_string(),
443        installed: installed_version.map(str::to_string),
444        matches: installed_version.map(|installed| installed == vendored_version),
445    };
446    Ok(DriftReport {
447        in_sync: drifted_count == 0,
448        drifted_count,
449        version,
450        sections,
451    })
452}
453
454fn print_human_report(report: &DriftReport) {
455    if report.in_sync {
456        println!("codex-schema drift: in sync");
457    } else {
458        println!(
459            "codex-schema drift: {} method(s) drifted from the vendored schema",
460            report.drifted_count
461        );
462    }
463
464    match (&report.version.installed, report.version.matches) {
465        (Some(installed), Some(true)) => {
466            println!("  codex version: {installed} (matches vendored)");
467        }
468        (Some(installed), Some(false)) => {
469            println!(
470                "  codex version: vendored={} installed={} (MISMATCH)",
471                report.version.vendored, installed
472            );
473        }
474        _ => {
475            println!(
476                "  codex version: vendored={} installed=unknown (codex --version unavailable)",
477                report.version.vendored
478            );
479        }
480    }
481    println!();
482
483    for section in &report.sections {
484        if section.in_sync() {
485            println!(
486                "{}: {} -> {} (in sync)",
487                section.section, section.vendored_count, section.installed_count
488            );
489            continue;
490        }
491        println!(
492            "{}: {} -> {} (+{} added, -{} removed, ~{} changed)",
493            section.section,
494            section.vendored_count,
495            section.installed_count,
496            section.added.len(),
497            section.removed.len(),
498            section.changed.len()
499        );
500        for entry in &section.added {
501            println!(
502                "    + {} (params={:?} optional={} response={:?})",
503                entry.method, entry.params_type, entry.params_optional, entry.response_type
504            );
505        }
506        for entry in &section.removed {
507            println!(
508                "    - {} (params={:?} optional={} response={:?})",
509                entry.method, entry.params_type, entry.params_optional, entry.response_type
510            );
511        }
512        for changed in &section.changed {
513            println!("    ~ {}", changed.method);
514            if changed.vendored.params_type != changed.installed.params_type {
515                println!(
516                    "        params_type: {:?} -> {:?}",
517                    changed.vendored.params_type, changed.installed.params_type
518                );
519            }
520            if changed.vendored.params_optional != changed.installed.params_optional {
521                println!(
522                    "        params_optional: {} -> {}",
523                    changed.vendored.params_optional, changed.installed.params_optional
524                );
525            }
526            if changed.vendored.response_type != changed.installed.response_type {
527                println!(
528                    "        response_type: {:?} -> {:?}",
529                    changed.vendored.response_type, changed.installed.response_type
530                );
531            }
532        }
533    }
534
535    println!();
536    if report.in_sync {
537        println!("no remediation needed.");
538    } else {
539        println!(
540            "remediation: cargo xtask codex-schema regen <path-to-codex-generate-json-schema-output-dir>"
541        );
542    }
543}
544
545#[cfg(test)]
546#[path = "drift_tests.rs"]
547mod tests;